diff --git a/scripts/subagent_delivery_watchdog.mjs b/scripts/subagent_delivery_watchdog.mjs index 81d5bd2..fd44a60 100755 --- a/scripts/subagent_delivery_watchdog.mjs +++ b/scripts/subagent_delivery_watchdog.mjs @@ -256,6 +256,83 @@ function decideRecoveryAction(payload, status) { return 'fetch_history'; } +function buildReportingPayload(payload, status, recoveryDecision) { + const detail = { + runId: typeof payload?.runId === 'string' ? payload.runId : null, + childSessionKey: typeof payload?.childSessionKey === 'string' ? payload.childSessionKey : null, + status, + recoveryDecision, + }; + + if (status === 'suspect_delivery_failure') { + return { + ownerVisible: true, + category: 'suspect_delivery_failure', + decision: 'report', + summary: 'Subagent delivery is suspected to have failed after crossing SLA.', + detail, + }; + } + + if (status === 'done_but_not_forwarded' && recoveryDecision === 'fetch_history') { + return { + ownerVisible: true, + category: 'done_but_not_forwarded', + decision: 'fetch_history', + summary: 'Child run is done but no forwarded completion receipt is confirmed yet.', + detail, + }; + } + + if (status === 'done_but_not_forwarded' && recoveryDecision === 'respawn') { + return { + ownerVisible: true, + category: 'done_but_not_forwarded', + decision: 'respawn', + summary: 'Child run is done but recovery already failed once; respawn is the next conservative step.', + detail, + }; + } + + if (status === 'done_but_not_forwarded' && recoveryDecision === 'blocked') { + return { + ownerVisible: true, + category: 'done_but_not_forwarded', + decision: 'blocked', + summary: 'Child run is still not forwarded after repeated recovery attempts; owner attention is required.', + detail, + }; + } + + if (status === 'completed') { + return { + ownerVisible: false, + category: 'completed', + decision: 'none', + summary: 'Completion receipt is present; no owner-visible report is needed.', + detail, + }; + } + + if (status === 'active') { + return { + ownerVisible: false, + category: 'active', + decision: 'none', + summary: 'Dispatch is still within SLA; no owner-visible report is needed.', + detail, + }; + } + + return { + ownerVisible: false, + category: status, + decision: 'none', + summary: 'No owner-visible report is needed.', + detail, + }; +} + function main() { const args = parseArgs(process.argv.slice(2)); @@ -270,6 +347,7 @@ function main() { const completionWrite = writeCompletionReceiptState(inputPayload); const status = recomputeStatus(inputPayload); const recoveryDecision = decideRecoveryAction(inputPayload, status); + const reporting = buildReportingPayload(inputPayload, status, recoveryDecision); if ('content' in input) { delete input.content; @@ -286,7 +364,7 @@ function main() { const response = { ok: true, tool: 'subagent_delivery_watchdog', - version: 'skeleton-v4', + version: 'skeleton-v5', mode: 'receipt-write', args: { compact: args.compact, @@ -296,9 +374,10 @@ function main() { result: { status, message: status === 'not_implemented' - ? 'Dispatch and completion receipt writes are implemented; status recompute only handles basic active/suspect/completed states.' + ? 'Dispatch and completion receipt writes are implemented; status recompute only handles basic active/suspect/completed states plus conservative recovery/reporting decisions.' : 'Basic watchdog status recompute completed.', recoveryDecision, + reporting, records, dispatchReceiptWrite: dispatchWrite, completionReceiptWrite: completionWrite, diff --git a/scripts/test_subagent_delivery_watchdog.mjs b/scripts/test_subagent_delivery_watchdog.mjs index b0c996e..03e87f0 100644 --- a/scripts/test_subagent_delivery_watchdog.mjs +++ b/scripts/test_subagent_delivery_watchdog.mjs @@ -59,6 +59,25 @@ function printResult(prefix, name, detail = '') { process.stdout.write(`${prefix} ${name}${suffix}\n`); } +function runFixture(payloadInput, fixtureName = 'fixture.json') { + const runner = createFixtureRunner(); + + try { + const inputPath = runner.writeFixture(fixtureName, payloadInput); + const result = runner.runWatchdog(['--compact', '--input', inputPath]); + + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); + assert.equal(result.stderr, ''); + + return { + payload: JSON.parse(result.stdout), + inputPath, + }; + } finally { + runner.cleanup(); + } +} + test('fixture runner can invoke watchdog skeleton with a generated input file', () => { const runner = createFixtureRunner(); @@ -84,8 +103,6 @@ test('fixture runner can invoke watchdog skeleton with a generated input file', } }); - - test('watchdog reports active before SLA when dispatch exists and no completion receipt has arrived yet', () => { const runner = createFixtureRunner(); @@ -100,8 +117,7 @@ test('watchdog reports active before SLA when dispatch exists and no completion const result = runner.runWatchdog(['--compact', '--input', inputPath]); - assert.equal(result.status, 0, `expected zero exit status, got ${result.status} -${result.stderr}`); + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); assert.equal(result.stderr, ''); const payload = JSON.parse(result.stdout); @@ -114,7 +130,6 @@ ${result.stderr}`); } }); - test('watchdog reports suspect delivery failure after SLA when dispatch exists and no completion receipt has arrived yet', () => { const runner = createFixtureRunner(); @@ -129,8 +144,7 @@ test('watchdog reports suspect delivery failure after SLA when dispatch exists a const result = runner.runWatchdog(['--compact', '--input', inputPath]); - assert.equal(result.status, 0, `expected zero exit status, got ${result.status} -${result.stderr}`); + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); assert.equal(result.stderr, ''); const payload = JSON.parse(result.stdout); @@ -143,7 +157,6 @@ ${result.stderr}`); } }); - test('watchdog reports completed when dispatch exists and completion receipt has arrived', () => { const runner = createFixtureRunner(); @@ -159,8 +172,7 @@ test('watchdog reports completed when dispatch exists and completion receipt has const result = runner.runWatchdog(['--compact', '--input', inputPath]); - assert.equal(result.status, 0, `expected zero exit status, got ${result.status} -${result.stderr}`); + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); assert.equal(result.stderr, ''); const payload = JSON.parse(result.stdout); @@ -188,8 +200,7 @@ test('watchdog reports done but not forwarded when child run is marked done with const result = runner.runWatchdog(['--compact', '--input', inputPath]); - assert.equal(result.status, 0, `expected zero exit status, got ${result.status} -${result.stderr}`); + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); assert.equal(result.stderr, ''); const payload = JSON.parse(result.stdout); @@ -202,8 +213,6 @@ ${result.stderr}`); } }); - - test('watchdog prefers fetch_history recovery when child run is done but no forwarded completion receipt exists', () => { const runner = createFixtureRunner(); @@ -221,8 +230,7 @@ test('watchdog prefers fetch_history recovery when child run is done but no forw const result = runner.runWatchdog(['--compact', '--input', inputPath]); - assert.equal(result.status, 0, `expected zero exit status, got ${result.status} -${result.stderr}`); + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); assert.equal(result.stderr, ''); const payload = JSON.parse(result.stdout); @@ -234,8 +242,6 @@ ${result.stderr}`); } }); - - test('watchdog escalates to respawn when fetch_history recovery was already attempted and delivery is still not forwarded', () => { const runner = createFixtureRunner(); @@ -255,8 +261,7 @@ test('watchdog escalates to respawn when fetch_history recovery was already atte const result = runner.runWatchdog(['--compact', '--input', inputPath]); - assert.equal(result.status, 0, `expected zero exit status, got ${result.status} -${result.stderr}`); + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); assert.equal(result.stderr, ''); const payload = JSON.parse(result.stdout); @@ -268,7 +273,6 @@ ${result.stderr}`); } }); - test('watchdog escalates to blocked when respawn recovery was already attempted and delivery is still not forwarded', () => { const runner = createFixtureRunner(); @@ -288,8 +292,7 @@ test('watchdog escalates to blocked when respawn recovery was already attempted const result = runner.runWatchdog(['--compact', '--input', inputPath]); - assert.equal(result.status, 0, `expected zero exit status, got ${result.status} -${result.stderr}`); + assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`); assert.equal(result.stderr, ''); const payload = JSON.parse(result.stdout); @@ -301,6 +304,211 @@ ${result.stderr}`); } }); +test('owner-visible reporting marks suspect_delivery_failure as reportable with minimal payload', () => { + const { payload } = runFixture({ + runId: 'fixture-run-report-suspect', + childSessionKey: 'session:report-suspect', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:15:00.000Z', + }, 'report-suspect.json'); + + assert.deepEqual(payload.result.reporting, { + ownerVisible: true, + category: 'suspect_delivery_failure', + decision: 'report', + summary: 'Subagent delivery is suspected to have failed after crossing SLA.', + detail: { + runId: 'fixture-run-report-suspect', + childSessionKey: 'session:report-suspect', + status: 'suspect_delivery_failure', + recoveryDecision: null, + }, + }); +}); + +test('owner-visible reporting marks done_but_not_forwarded plus fetch_history as reportable with minimal payload', () => { + const { payload } = runFixture({ + runId: 'fixture-run-report-fetch-history', + childSessionKey: 'session:report-fetch-history', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:05:00.000Z', + childRunStatus: 'done', + recoveryAttemptCount: 0, + }, 'report-fetch-history.json'); + + assert.deepEqual(payload.result.reporting, { + ownerVisible: true, + category: 'done_but_not_forwarded', + decision: 'fetch_history', + summary: 'Child run is done but no forwarded completion receipt is confirmed yet.', + detail: { + runId: 'fixture-run-report-fetch-history', + childSessionKey: 'session:report-fetch-history', + status: 'done_but_not_forwarded', + recoveryDecision: 'fetch_history', + }, + }); +}); + +test('owner-visible reporting marks respawn as reportable with minimal payload', () => { + const { payload } = runFixture({ + runId: 'fixture-run-report-respawn', + childSessionKey: 'session:report-respawn', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:06:00.000Z', + childRunStatus: 'done', + recoveryAttemptCount: 1, + recoveryAction: 'fetch_history', + }, 'report-respawn.json'); + + assert.deepEqual(payload.result.reporting, { + ownerVisible: true, + category: 'done_but_not_forwarded', + decision: 'respawn', + summary: 'Child run is done but recovery already failed once; respawn is the next conservative step.', + detail: { + runId: 'fixture-run-report-respawn', + childSessionKey: 'session:report-respawn', + status: 'done_but_not_forwarded', + recoveryDecision: 'respawn', + }, + }); +}); + +test('owner-visible reporting marks blocked as reportable with minimal payload', () => { + const { payload } = runFixture({ + runId: 'fixture-run-report-blocked', + childSessionKey: 'session:report-blocked', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:07:00.000Z', + childRunStatus: 'done', + recoveryAttemptCount: 2, + recoveryAction: 'respawn', + }, 'report-blocked.json'); + + assert.deepEqual(payload.result.reporting, { + ownerVisible: true, + category: 'done_but_not_forwarded', + decision: 'blocked', + summary: 'Child run is still not forwarded after repeated recovery attempts; owner attention is required.', + detail: { + runId: 'fixture-run-report-blocked', + childSessionKey: 'session:report-blocked', + status: 'done_but_not_forwarded', + recoveryDecision: 'blocked', + }, + }); +}); + +test('scenario matrix: normal completion stays non-owner-visible and carries no recovery action', () => { + const { payload } = runFixture({ + runId: 'fixture-scenario-normal-completion', + childSessionKey: 'session:scenario-normal-completion', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:05:00.000Z', + completionReceiptAt: '2026-04-24T10:04:00.000Z', + forwardedToMain: true, + }, 'scenario-normal-completion.json'); + + assert.equal(payload.result.status, 'completed'); + assert.equal(payload.result.recoveryDecision, null); + assert.deepEqual(payload.result.reporting, { + ownerVisible: false, + category: 'completed', + decision: 'none', + summary: 'Completion receipt is present; no owner-visible report is needed.', + detail: { + runId: 'fixture-scenario-normal-completion', + childSessionKey: 'session:scenario-normal-completion', + status: 'completed', + recoveryDecision: null, + }, + }); +}); + +test('scenario matrix: slow-but-active stays non-owner-visible before SLA', () => { + const { payload } = runFixture({ + runId: 'fixture-scenario-slow-but-active', + childSessionKey: 'session:scenario-slow-but-active', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:09:59.000Z', + }, 'scenario-slow-but-active.json'); + + assert.equal(payload.result.status, 'active'); + assert.equal(payload.result.recoveryDecision, null); + assert.deepEqual(payload.result.reporting, { + ownerVisible: false, + category: 'active', + decision: 'none', + summary: 'Dispatch is still within SLA; no owner-visible report is needed.', + detail: { + runId: 'fixture-scenario-slow-but-active', + childSessionKey: 'session:scenario-slow-but-active', + status: 'active', + recoveryDecision: null, + }, + }); +}); + +test('scenario matrix: done-but-not-forwarded resolves to fetch_history reporting decision', () => { + const { payload } = runFixture({ + runId: 'fixture-scenario-fetch-history', + childSessionKey: 'session:scenario-fetch-history', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:05:00.000Z', + childRunStatus: 'done', + recoveryAttemptCount: 0, + }, 'scenario-fetch-history.json'); + + assert.equal(payload.result.status, 'done_but_not_forwarded'); + assert.equal(payload.result.recoveryDecision, 'fetch_history'); + assert.equal(payload.result.reporting.ownerVisible, true); + assert.equal(payload.result.reporting.decision, 'fetch_history'); +}); + +test('scenario matrix: repeated failure escalates to respawn reporting decision', () => { + const { payload } = runFixture({ + runId: 'fixture-scenario-respawn', + childSessionKey: 'session:scenario-respawn', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:06:00.000Z', + childRunStatus: 'done', + recoveryAttemptCount: 1, + recoveryAction: 'fetch_history', + }, 'scenario-respawn.json'); + + assert.equal(payload.result.status, 'done_but_not_forwarded'); + assert.equal(payload.result.recoveryDecision, 'respawn'); + assert.equal(payload.result.reporting.ownerVisible, true); + assert.equal(payload.result.reporting.decision, 'respawn'); +}); + +test('scenario matrix: repeated failure can escalate to blocked reporting decision', () => { + const { payload } = runFixture({ + runId: 'fixture-scenario-blocked', + childSessionKey: 'session:scenario-blocked', + dispatchAt: '2026-04-24T10:00:00.000Z', + expectedBy: '2026-04-24T10:10:00.000Z', + currentTime: '2026-04-24T10:07:00.000Z', + childRunStatus: 'done', + recoveryAttemptCount: 2, + recoveryAction: 'respawn', + }, 'scenario-blocked.json'); + + assert.equal(payload.result.status, 'done_but_not_forwarded'); + assert.equal(payload.result.recoveryDecision, 'blocked'); + assert.equal(payload.result.reporting.ownerVisible, true); + assert.equal(payload.result.reporting.decision, 'blocked'); +}); + test('fixture runner exposes missing-input behavior for future fail-first cases', () => { const runner = createFixtureRunner();