работник больше не ждёт ответа вечно: сутки без ответа — гаснет
Задача 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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`, и пока оно не подтвердилось, приговора
|
||||
// нет вовсе: остальные пределы досмотрятся на следующем круге, через полминуты.
|
||||
|
||||
Reference in New Issue
Block a user