diff --git a/night.config.example.json b/night.config.example.json index 6dcd41b9..24060810 100644 --- a/night.config.example.json +++ b/night.config.example.json @@ -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, diff --git a/tools/night/questions.test.mjs b/tools/night/questions.test.mjs index dba575d3..00b54323 100644 --- a/tools/night/questions.test.mjs +++ b/tools/night/questions.test.mjs @@ -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); + }); +}); diff --git a/tools/night/supervisor.mjs b/tools/night/supervisor.mjs index d49d5647..66049e4b 100644 --- a/tools/night/supervisor.mjs +++ b/tools/night/supervisor.mjs @@ -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`, и пока оно не подтвердилось, приговора // нет вовсе: остальные пределы досмотрятся на следующем круге, через полминуты.