работник больше не ждёт ответа вечно: сутки без ответа — гаснет

Задача 7 куска 5. 🔴 Зачем это нужно, хотя ждущий никому не мешает: ожидание ответа предел
часов НЕ ест (решение владельца 56) — и ровно поэтому вечное ожидание опасно. Спросивший
и не дождавшийся не гаснет ни по пределу часов, ни по молчанию (признаки жизни у него идут,
он исправно отчитывается о себе). Он висел бы, занимая место в потолке работников, пока
кто-нибудь не заметит его рукой.

· срок считается НЕЗАВИСИМО от того, ждёт работник или идёт дальше «в расчёте на ответ»
  (решение 53): вопрос без ответа сутки означает, что он всё это время работает вслепую;
· из нескольких просроченных берётся САМЫЙ РАННИЙ — он дольше держит работника, и с него
  началось ожидание. Возьми мы последний, причина в утренней бумаге назвала бы не тот вопрос;
· 🟢 ночной вопрос начинает отсчёт с ближайшего утра — это уже умело правило `deadlineHit`:
  спрашивать среди ночи и через сутки гасить за то, что владелец спал, значит наказывать
  его за сон;
· 🔴 СТАРШИНСТВО ДВУХ ПРИЧИН НАЗВАНО ВСЛУХ: приговор пределов старше просрочки — пределы
  про место, деньги и часы, а просрочка про то, что ответа всё нет. Совпади они в одну
  минуту, владелец прочитает ту причину, которая дороже;
· 🪤 своей ветки гашения НЕ заводится: приговор дописывается в тот же `verdict`, дальше
  одна дорога. Второй гаситель на то же дело разъехался бы с первым молча.

🔴 СВОЯ ОШИБКА, ПОЙМАННАЯ СВОЕЙ ЖЕ ПРОВЕРКОЙ НА СВЕЖЕМ ВОПРОСЕ: `deadlineHit` отдаёт
ОБЪЕКТ `{hit, from, reason}`, а не «да/нет». Я проверял его на истинность — а объект истинен
ВСЕГДА, и правило гасило бы каждого, кто задал хоть один вопрос, через мгновение после
вопроса. Красное поймало сразу; проверка «свежий вопрос срока не переступил» стоила ровно
трёх строк и окупилась в первый же прогон.

🟢 И чужой сторож потребовал своё: настройка `owner_answer_deadline_hours` добавлена
в образец `night.config.example.json` — правило «в образце есть каждая настройка, которую
код берёт из cfg». Умолчание 24 ч; о том, что срок взят умолчанием, владелец узнаёт
СТРОКОЙ в сводке (один раз за прогон), а не молчанием.

Прогон: 3015 проверок в 114 файлах, код 0 — ровно предсказанное (было 3009, +6 моих).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-08 10:40:19 +03:00
parent 6689e110d8
commit f07ff7a9d6
3 changed files with 135 additions and 2 deletions
+1
View File
@@ -6,6 +6,7 @@
"minut_bez_dela": 240,
"odinakovyh_otchyotov_podryad": 5,
"pause_wait_minutes": 20,
"owner_answer_deadline_hours": 24,
"max_tasks_at_once": 1,
"max_workers_per_task": 7,
"workspace_max_age_days": 7,
+58 -1
View File
@@ -31,7 +31,7 @@ import {
} from './questions.mjs';
import { STOP_AND_ASK } from './worker-prompt.mjs';
import { REPO_ROOT, workerInboxDir } from './paths.mjs';
import { collectQuestions, deliverAnswers } from './supervisor.mjs';
import { collectQuestions, deliverAnswers, overdueQuestion } from './supervisor.mjs';
import { main } from './cli.mjs';
import { readLines } from './state.mjs';
import { bezPrimechaniy } from './schyot-zovushchih.mjs';
@@ -1183,3 +1183,60 @@ describe('🔴 задача 8: владельцу есть чем ответит
expect(podskazka, 'в списке команд нет `answer` — владелец о ней не узнает').toMatch(/answer/);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ЗАДАЧА 7 КУСКА 5 — РАБОТНИК НЕ ЖДЁТ ОТВЕТА ВЕЧНО
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
//
// 🔴 Ждущий ответа не съедает часы (решение владельца 56) — и это ровно то, что делает
// вечное ожидание опасным: работник, спросивший и не дождавшийся, не гаснет ни по пределу
// часов, ни по молчанию (признаки жизни у него идут). Без этого правила он висел бы,
// занимая место в потолке работников, пока кто-нибудь не заметит его руками.
describe('🔴 задача 7: вопрос без ответа дольше срока — работника гасим', () => {
const SUTKI = 24 * 3600_000;
const T = msk(2026, 8, 2, 12, 0);
it('П121. вопрос старше срока найден — с номером и причиной словами', () => {
askOwner(qPath, { run_id: 'r-1', worker_id: 'w-1', text: 'сносить ли таблицу', item: '5', at: T - SUTKI - 60_000 });
const beda = overdueQuestion({ qPath, workerId: 'w-1', now: T, hours: 24 });
expect(beda, 'просроченный вопрос не найден — работник висит вечно').not.toBe(null);
expect(beda.number).toBe(1);
expect(beda.asked_at).toBe(T - SUTKI - 60_000);
expect(beda.reason, 'причина не сказана словами — владелец не поймёт, за что погашен').toMatch(/сутки|24|ответа/i);
});
it('П121. свежий вопрос срока не переступил', () => {
askOwner(qPath, { run_id: 'r-1', worker_id: 'w-1', text: 'свежий', item: '5', at: T - 3600_000 });
expect(overdueQuestion({ qPath, workerId: 'w-1', now: T, hours: 24 })).toBe(null);
});
it('П121. отвеченный вопрос просроченным не считается, даже если он древний', () => {
// 🔴 Иначе работник гас бы за вопрос, на который владелец давно ответил, — и причина
// в утренней бумаге была бы прямой ложью.
askOwner(qPath, { run_id: 'r-1', worker_id: 'w-1', text: 'древний', item: '5', at: T - 3 * SUTKI });
answerQuestion(qPath, 1, 'да', T - 2 * SUTKI);
expect(overdueQuestion({ qPath, workerId: 'w-1', now: T, hours: 24 })).toBe(null);
});
it('П121. чужой просроченный вопрос этого работника не касается', () => {
askOwner(qPath, { run_id: 'r-1', worker_id: 'w-2', text: 'чужой', item: '9', at: T - 3 * SUTKI });
expect(overdueQuestion({ qPath, workerId: 'w-1', now: T, hours: 24 })).toBe(null);
});
it('Э13. просрочены двое — берётся САМЫЙ РАННИЙ, о нём и причина', () => {
// Ранний важнее: он дольше держит работника, и с него началось ожидание.
askOwner(qPath, { run_id: 'r-1', worker_id: 'w-1', text: 'первый', item: '5', at: T - 3 * SUTKI });
askOwner(qPath, { run_id: 'r-1', worker_id: 'w-1', text: 'второй', item: '6', at: T - 2 * SUTKI });
expect(overdueQuestion({ qPath, workerId: 'w-1', now: T, hours: 24 }).number).toBe(1);
});
it('Э14. круг надзирателя вправду зовёт правило срока', () => {
const vseh = (KRUG.match(/overdueQuestion\(/g) ?? []).length;
const obyavleniy = (KRUG.match(/export function overdueQuestion\(/g) ?? []).length;
expect(obyavleniy, 'объявления `overdueQuestion` в круге нет — примета устарела').toBe(1);
expect(vseh - obyavleniy, [
'В круге надзирателя нет ЗОВА `overdueQuestion` — только объявление.',
'Значит работник, не дождавшийся ответа, висит до утра, занимая место (правило 231).',
].join('\n')).toBeGreaterThanOrEqual(1);
});
});
+76 -1
View File
@@ -19,7 +19,7 @@ import { touchLife, readLife, lifePath } from './heartbeat.mjs';
// и до 07.08.2026 превращалось БЕЗ проверки: правило было написано и проверено, а звать
// его в живом круге было некому (правило 231). Правило живёт в одном месте, здесь —
// только зов; своей второй копии приговора тут нет и быть не должно.
import { prostoyLiVpravdu, askOwner, answerPolarity } from './questions.mjs';
import { prostoyLiVpravdu, askOwner, answerPolarity, deadlineHit, OWNER_ANSWER_DEADLINE_HOURS } from './questions.mjs';
import { workerInboxDir, lastRunPath, resolveBin, questionsPath, SUMMARY_LINES_FILE } from './paths.mjs';
// Русские слова для итоговых состояний живут в одном месте — в сводке (задача 9).
import { STATUS_WORD } from './summary.mjs';
@@ -705,6 +705,41 @@ export function deliverAnswers({ worktreeRoot, qPath, workerId, now }) {
return { dostavleno: nomera.length, nomera };
}
// --- кусок 5, задача 7: работник не ждёт ответа вечно --------------------------------
//
// 🔴 ЗАЧЕМ ЭТО НУЖНО, хотя ждущий никому не мешает. Ожидание ответа предел часов НЕ ест
// (решение владельца 56) — и ровно поэтому вечное ожидание опасно: спросивший и
// не дождавшийся не гаснет ни по пределу часов, ни по молчанию (признаки жизни у него
// идут — он исправно отчитывается о себе). Он висел бы, занимая место в потолке
// работников, пока кто-нибудь не заметит его рукой.
// 🪤 Гасит НЕ это правило: оно только называет беду. Гашение делает та же ветка круга,
// что и у прочих пределов, — второго гасителя на то же дело не заводится.
export function overdueQuestion({ qPath, workerId, now, hours }) {
if (!qPath || !existsSync(qPath)) return null;
const zhdut = readLines(qPath).filter(v => v
&& v.worker_id === workerId
&& (v.answer === null || v.answer === undefined)
&& Number.isFinite(v.asked_at));
if (zhdut.length === 0) return null;
// 🔴 Берётся САМЫЙ РАННИЙ: он дольше держит работника, и с него началось ожидание.
// Возьми мы последний — причина в утренней бумаге назвала бы владельцу не тот вопрос.
const ranniy = zhdut.reduce((a, b) => (a.asked_at <= b.asked_at ? a : b));
// 🪤 `deadlineHit` отдаёт ОБЪЕКТ `{ hit, from, reason }`, а не «да/нет». Проверять его
// на истинность бесполезно — объект истинен всегда, и правило гасило бы каждого, кто
// задал хоть один вопрос. Поймано своей же проверкой на свежем вопросе.
// 🟢 Отсчёт начинает САМО правило: ночной вопрос считается с ближайшего утра — спрашивать
// среди ночи и через сутки гасить за то, что владелец спал, значит наказывать его за сон.
const srok = deadlineHit({ asked_at: ranniy.asked_at, now, hours });
if (!srok.hit) return null;
const chasov = Math.floor((now - (srok.from ?? ranniy.asked_at)) / 3600_000);
return {
number: ranniy.number,
asked_at: ranniy.asked_at,
reason: `Погашен: ${chasov} ч ждёт ответа на вопрос № ${ranniy.number}${ranniy.text ?? 'текст вопроса не записан'}»), срок ожидания — ${hours} ч. Сделанное осталось на месте, ответить на вопрос можно и утром.`,
};
}
// --- команды владельца: правило -----------------------------------------------
// 🔴 Здесь только ПРАВИЛО — что делать с командой. Кто её читает из `.night/commands/`
// и кто снимает общую команду «стоп всё» со всего состава прогона — кусок 3.
@@ -1566,6 +1601,10 @@ export async function supervise({
// потому что молчаливое «наверное, чисто» здесь стоило бы владельцу денег.
let gryazNaOtkrytiiProstoya = null;
// 🔴 Кусок 5, задача 7. О сроке ожидания, взятом умолчанием, владелец узнаёт СТРОКОЙ,
// а не молчанием — но один раз за прогон, а не каждые полминуты.
let skazalProSrokOtveta = false;
async function closeRunIfLast(at) {
// 🔴 Прогон закрывает надзиратель ПОСЛЕДНЕГО погасшего работника — больше некому:
// оркестратор к этому времени давно умер вместе с сессией помощника.
@@ -2203,6 +2242,24 @@ export async function supervise({
const diskBadNeeded = cfg?.disk_bad_probes ?? DISK_BAD_PROBES_DEFAULT;
const diskBadUnconfirmed = !disk?.ok && diskBadInARow < diskBadNeeded;
// ━━━ 2г. СУТКИ БЕЗ ОТВЕТА (кусок 5, задача 7) ━━━
// 🔴 Считается НЕЗАВИСИМО от того, ждёт работник или работает дальше «в расчёте
// на ответ» (решение 53): вопрос без ответа сутки означает, что он всё это время идёт
// вслепую, и цена ошибки растёт с каждым часом.
// 🪤 Умолчание срока — из правила (`OWNER_ANSWER_DEADLINE_HOURS`), и о том, что оно
// взято умолчанием, владелец узнаёт СТРОКОЙ, а не молчанием: то же правило, что у срока
// забытой паузы. Строка кладётся один раз за прогон, а не каждые полминуты.
const srokOtveta = cfg?.owner_answer_deadline_hours ?? OWNER_ANSWER_DEADLINE_HOURS;
if (cfg?.owner_answer_deadline_hours === undefined && !skazalProSrokOtveta) {
skazalProSrokOtveta = true;
appendLine(join(runDirPath, SUMMARY_LINES_FILE), {
at: t,
worker_id: workerId,
line: `${workerId}: срок ожидания ответа владельца в настройках не назван — взято умолчание ${srokOtveta} ч.`,
});
}
const prosrochka = overdueQuestion({ qPath, workerId, now: t, hours: srokOtveta });
const verdict = checkSelfLimits({
billable_ms: billableMs(spansHere),
// 🔴 Запасное число — не восьмёрка, а сутки владельца (решение 37). Прежде здесь стояла
@@ -2220,6 +2277,24 @@ export async function supervise({
current_item: currentItem(worktreeRoot),
});
// 🔴 СТАРШИНСТВО ДВУХ ПРИЧИН НАЗВАНО ВСЛУХ (кусок 5, задача 7). Приговор пределов
// старше просрочки, и это не вкус: пределы — про место на сервере, про деньги и про
// часы владельца, а просрочка про то, что ответа всё нет. Совпади они в одну минуту,
// владелец должен прочитать в утренней бумаге ту причину, которая дороже.
// 🪤 Своей ветки гашения здесь НЕ заводится: приговор дописывается в тот же `verdict`,
// и дальше всё идёт одной дорогой. Второй гаситель на то же дело разъехался бы
// с первым молча.
if (!verdict.extinguish && prosrochka) {
verdict.extinguish = true;
verdict.reason = prosrochka.reason;
verdict.final_status = verdict.final_status ?? 'extinguished';
logEvent(runDirPath, {
kind: 'sutki_bez_otveta',
worker: workerId,
vopros: prosrochka.number,
}, t);
}
if (verdict.extinguish && diskBadUnconfirmed) {
// Место — первое правило в `checkSelfLimits`, и пока оно не подтвердилось, приговора
// нет вовсе: остальные пределы досмотрятся на следующем круге, через полминуты.