feat: export continuity hard-gate and watchdog workstream
This commit is contained in:
109
scripts/approved_plan_continuity_gate.mjs
Executable file
109
scripts/approved_plan_continuity_gate.mjs
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
|
||||
const LEGAL_TERMINAL_STATES = new Set(['waiting_user', 'blocked', 'pending_verification']);
|
||||
|
||||
function parseArgs(argv) {
|
||||
let inputPath = null;
|
||||
let compact = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === '--input') {
|
||||
inputPath = argv[i + 1] ?? null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--input=')) {
|
||||
inputPath = arg.slice('--input='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--compact') {
|
||||
compact = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { inputPath, compact };
|
||||
}
|
||||
|
||||
function readInput(inputPath) {
|
||||
if (!inputPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'missing_required_input',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(inputPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
ok: true,
|
||||
bytes: Buffer.byteLength(raw, 'utf8'),
|
||||
preview: raw.slice(0, 0),
|
||||
parsed,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateContinuity(payload) {
|
||||
const taskComplete = payload?.taskState === 'complete';
|
||||
const nextAction = payload?.nextDerivedAction ?? payload?.derivedAction ?? null;
|
||||
const nextActionKnown = nextAction != null;
|
||||
const hasDispatchReceipt = payload?.dispatchReceipt != null;
|
||||
const closureState = payload?.replyClosureState ?? null;
|
||||
const isLegalTerminalState = LEGAL_TERMINAL_STATES.has(closureState);
|
||||
|
||||
if (taskComplete && nextActionKnown && !hasDispatchReceipt && !isLegalTerminalState) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'continuity_failure',
|
||||
verdict: 'continuity_failure',
|
||||
reason: 'missing_dispatch_receipt',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 'pass',
|
||||
verdict: 'pass',
|
||||
};
|
||||
}
|
||||
|
||||
const { inputPath, compact } = parseArgs(process.argv.slice(2));
|
||||
const input = readInput(inputPath);
|
||||
const evaluation = input.ok ? evaluateContinuity(input.parsed) : {
|
||||
ok: false,
|
||||
status: 'input_error',
|
||||
verdict: 'input_error',
|
||||
};
|
||||
|
||||
const response = {
|
||||
...evaluation,
|
||||
gate: 'approved_plan_continuity',
|
||||
compact,
|
||||
inputPath,
|
||||
input: {
|
||||
ok: input.ok,
|
||||
...(input.ok
|
||||
? {
|
||||
bytes: input.bytes,
|
||||
preview: input.preview,
|
||||
}
|
||||
: {
|
||||
error: input.error,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify(response)}
|
||||
`);
|
||||
194
scripts/approved_plan_dispatch_binding.mjs
Executable file
194
scripts/approved_plan_dispatch_binding.mjs
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_RECEIPT_DIR = path.resolve(process.cwd(), 'state/approved-plan-continuity');
|
||||
|
||||
function parseArgs(argv) {
|
||||
let inputPath = null;
|
||||
let compact = false;
|
||||
let receiptDir = DEFAULT_RECEIPT_DIR;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === '--input') {
|
||||
inputPath = argv[i + 1] ?? null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--input=')) {
|
||||
inputPath = arg.slice('--input='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--receipt-dir') {
|
||||
receiptDir = argv[i + 1] ? path.resolve(argv[i + 1]) : receiptDir;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--receipt-dir=')) {
|
||||
receiptDir = path.resolve(arg.slice('--receipt-dir='.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--compact') {
|
||||
compact = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { inputPath, compact, receiptDir };
|
||||
}
|
||||
|
||||
function readInput(inputPath) {
|
||||
if (!inputPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'missing_required_input',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(inputPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
ok: true,
|
||||
bytes: Buffer.byteLength(raw, 'utf8'),
|
||||
parsed,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function slugifySegment(value) {
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function buildReceipt(payload) {
|
||||
const nextAction = payload?.nextDerivedAction ?? payload?.derivedAction ?? null;
|
||||
const receipt = {
|
||||
planId: payload?.planId ?? null,
|
||||
currentTask: payload?.currentTask ?? null,
|
||||
nextDerivedAction: nextAction,
|
||||
dispatchedAt: payload?.dispatchedAt ?? null,
|
||||
dispatchRunId: payload?.dispatchRunId ?? null,
|
||||
childSessionKey: payload?.childSessionKey ?? null,
|
||||
replyClosureState: payload?.replyClosureState ?? null,
|
||||
};
|
||||
|
||||
return receipt;
|
||||
}
|
||||
|
||||
function validateReceipt(receipt) {
|
||||
const missing = [];
|
||||
|
||||
for (const field of [
|
||||
'planId',
|
||||
'currentTask',
|
||||
'nextDerivedAction',
|
||||
'dispatchedAt',
|
||||
'dispatchRunId',
|
||||
'childSessionKey',
|
||||
'replyClosureState',
|
||||
]) {
|
||||
if (receipt[field] == null) {
|
||||
missing.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
const planIdSafe = slugifySegment(receipt.planId ?? '');
|
||||
const dispatchRunIdSafe = slugifySegment(receipt.dispatchRunId ?? '');
|
||||
|
||||
if (!planIdSafe) missing.push('planId_filesystem_safe');
|
||||
if (!dispatchRunIdSafe) missing.push('dispatchRunId_filesystem_safe');
|
||||
|
||||
return {
|
||||
ok: missing.length === 0,
|
||||
missing,
|
||||
planIdSafe,
|
||||
dispatchRunIdSafe,
|
||||
};
|
||||
}
|
||||
|
||||
function writeReceipt({ receipt, receiptDir, planIdSafe, dispatchRunIdSafe }) {
|
||||
fs.mkdirSync(receiptDir, { recursive: true });
|
||||
const receiptPath = path.join(receiptDir, `receipt-${planIdSafe}-${dispatchRunIdSafe}.json`);
|
||||
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
||||
return receiptPath;
|
||||
}
|
||||
|
||||
const { inputPath, compact, receiptDir } = parseArgs(process.argv.slice(2));
|
||||
const input = readInput(inputPath);
|
||||
|
||||
let response;
|
||||
|
||||
if (!input.ok) {
|
||||
response = {
|
||||
ok: false,
|
||||
status: 'input_error',
|
||||
binding: 'approved_plan_dispatch',
|
||||
compact,
|
||||
inputPath,
|
||||
receipt: null,
|
||||
receiptPath: null,
|
||||
input: {
|
||||
ok: false,
|
||||
error: input.error,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const receipt = buildReceipt(input.parsed);
|
||||
const validation = validateReceipt(receipt);
|
||||
|
||||
if (!validation.ok) {
|
||||
response = {
|
||||
ok: false,
|
||||
status: 'missing_required_receipt_fields',
|
||||
binding: 'approved_plan_dispatch',
|
||||
compact,
|
||||
inputPath,
|
||||
receipt,
|
||||
receiptPath: null,
|
||||
missing: validation.missing,
|
||||
input: {
|
||||
ok: true,
|
||||
bytes: input.bytes,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const receiptPath = writeReceipt({
|
||||
receipt,
|
||||
receiptDir,
|
||||
planIdSafe: validation.planIdSafe,
|
||||
dispatchRunIdSafe: validation.dispatchRunIdSafe,
|
||||
});
|
||||
|
||||
response = {
|
||||
ok: true,
|
||||
status: 'receipt_written',
|
||||
binding: 'approved_plan_dispatch',
|
||||
compact,
|
||||
inputPath,
|
||||
receipt,
|
||||
receiptPath,
|
||||
input: {
|
||||
ok: true,
|
||||
bytes: input.bytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(response)}\n`);
|
||||
285
scripts/subagent_delivery_watchdog.mjs
Executable file
285
scripts/subagent_delivery_watchdog.mjs
Executable file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
||||
const STATE_DIR = path.join(ROOT_DIR, 'state', 'subagent-delivery-watchdog');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
compact: false,
|
||||
input: null,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === '--compact') {
|
||||
args.compact = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--help' || token === '-h') {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--input') {
|
||||
args.input = argv[i + 1] ?? null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('--input=')) {
|
||||
args.input = token.slice('--input='.length) || null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const lines = [
|
||||
'Usage: node scripts/subagent_delivery_watchdog.mjs [--compact] [--input <path>]',
|
||||
'',
|
||||
'Minimal CLI skeleton for the subagent delivery watchdog.',
|
||||
];
|
||||
process.stdout.write(`${lines.join('\n')}\n`);
|
||||
}
|
||||
|
||||
function tryReadInput(inputPath) {
|
||||
if (!inputPath) {
|
||||
return {
|
||||
path: null,
|
||||
exists: false,
|
||||
bytes: 0,
|
||||
preview: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(inputPath, 'utf8');
|
||||
return {
|
||||
path: inputPath,
|
||||
exists: true,
|
||||
bytes: Buffer.byteLength(content, 'utf8'),
|
||||
preview: content.slice(0, 200),
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: inputPath,
|
||||
exists: false,
|
||||
bytes: 0,
|
||||
preview: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson(content) {
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDispatchReceiptState(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { runId, childSessionKey, dispatchAt, expectedBy } = payload;
|
||||
|
||||
if (![runId, childSessionKey, dispatchAt, expectedBy].every((value) => typeof value === 'string' && value.length > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
|
||||
const statePath = path.join(STATE_DIR, `${runId}.json`);
|
||||
const dispatchRecord = {
|
||||
runId,
|
||||
childSessionKey,
|
||||
dispatchAt,
|
||||
expectedBy,
|
||||
};
|
||||
|
||||
fs.writeFileSync(statePath, `${JSON.stringify(dispatchRecord, null, 2)}\n`, 'utf8');
|
||||
|
||||
return {
|
||||
statePath,
|
||||
record: dispatchRecord,
|
||||
};
|
||||
}
|
||||
|
||||
function writeCompletionReceiptState(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { runId } = payload;
|
||||
const completionReceivedAt = payload.completionReceivedAt ?? payload.completionReceiptAt ?? null;
|
||||
const forwardedToMain = payload.forwardedToMain;
|
||||
const resultSource = payload.resultSource;
|
||||
|
||||
if (typeof runId !== 'string' || runId.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completionUpdates = {};
|
||||
|
||||
if (typeof completionReceivedAt === 'string' && completionReceivedAt.length > 0) {
|
||||
completionUpdates.completionReceivedAt = completionReceivedAt;
|
||||
}
|
||||
|
||||
if (typeof forwardedToMain === 'boolean') {
|
||||
completionUpdates.forwardedToMain = forwardedToMain;
|
||||
}
|
||||
|
||||
if (typeof resultSource === 'string' && resultSource.length > 0) {
|
||||
completionUpdates.resultSource = resultSource;
|
||||
}
|
||||
|
||||
if (Object.keys(completionUpdates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
|
||||
const statePath = path.join(STATE_DIR, `${runId}.json`);
|
||||
let currentRecord = {};
|
||||
|
||||
if (fs.existsSync(statePath)) {
|
||||
try {
|
||||
currentRecord = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
} catch {
|
||||
currentRecord = {};
|
||||
}
|
||||
}
|
||||
|
||||
const nextRecord = {
|
||||
...currentRecord,
|
||||
runId,
|
||||
...completionUpdates,
|
||||
};
|
||||
|
||||
fs.writeFileSync(statePath, `${JSON.stringify(nextRecord, null, 2)}\n`, 'utf8');
|
||||
|
||||
return {
|
||||
statePath,
|
||||
record: nextRecord,
|
||||
updatedFields: Object.keys(completionUpdates),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTime(value) {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function recomputeStatus(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return 'not_implemented';
|
||||
}
|
||||
|
||||
const completionReceivedAt = payload.completionReceivedAt ?? payload.completionReceiptAt ?? null;
|
||||
if (parseTime(completionReceivedAt) !== null) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
const hasDispatch = [payload.runId, payload.childSessionKey, payload.dispatchAt, payload.expectedBy].every(
|
||||
(value) => typeof value === 'string' && value.length > 0,
|
||||
);
|
||||
|
||||
if (!hasDispatch) {
|
||||
return 'not_implemented';
|
||||
}
|
||||
|
||||
const childRunStatus = typeof payload.childRunStatus === 'string'
|
||||
? payload.childRunStatus.trim().toLowerCase()
|
||||
: null;
|
||||
|
||||
if (childRunStatus === 'done') {
|
||||
return 'done_but_not_forwarded';
|
||||
}
|
||||
|
||||
const expectedBy = parseTime(payload.expectedBy);
|
||||
const currentTime = parseTime(payload.currentTime);
|
||||
|
||||
if (expectedBy === null || currentTime === null) {
|
||||
return 'not_implemented';
|
||||
}
|
||||
|
||||
if (currentTime > expectedBy) {
|
||||
return 'suspect_delivery_failure';
|
||||
}
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const input = tryReadInput(args.input);
|
||||
const inputPayload = input.exists ? tryParseJson(input.content) : null;
|
||||
const dispatchWrite = writeDispatchReceiptState(inputPayload);
|
||||
const completionWrite = writeCompletionReceiptState(inputPayload);
|
||||
const status = recomputeStatus(inputPayload);
|
||||
|
||||
if ('content' in input) {
|
||||
delete input.content;
|
||||
}
|
||||
|
||||
const records = [];
|
||||
if (dispatchWrite) {
|
||||
records.push(dispatchWrite.record);
|
||||
}
|
||||
if (completionWrite) {
|
||||
records.push(completionWrite.record);
|
||||
}
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
tool: 'subagent_delivery_watchdog',
|
||||
version: 'skeleton-v4',
|
||||
mode: 'receipt-write',
|
||||
args: {
|
||||
compact: args.compact,
|
||||
input: args.input,
|
||||
},
|
||||
input,
|
||||
result: {
|
||||
status,
|
||||
message: status === 'not_implemented'
|
||||
? 'Dispatch and completion receipt writes are implemented; status recompute only handles basic active/suspect/completed states.'
|
||||
: 'Basic watchdog status recompute completed.',
|
||||
records,
|
||||
dispatchReceiptWrite: dispatchWrite,
|
||||
completionReceiptWrite: completionWrite,
|
||||
},
|
||||
};
|
||||
|
||||
const spacing = args.compact ? 0 : 2;
|
||||
process.stdout.write(`${JSON.stringify(response, null, spacing)}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
421
scripts/test_approved_plan_continuity_gate.mjs
Normal file
421
scripts/test_approved_plan_continuity_gate.mjs
Normal file
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const gateScript = path.join(__dirname, 'approved_plan_continuity_gate.mjs');
|
||||
|
||||
function createFixture(files = {}) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'approved-plan-continuity-'));
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const filePath = path.join(root, relativePath);
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
path(...segments) {
|
||||
return path.join(root, ...segments);
|
||||
},
|
||||
cleanup() {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runGate({ args = [], stdin = null } = {}) {
|
||||
const result = spawnSync(process.execPath, [gateScript, ...args], {
|
||||
input: stdin,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
let json = null;
|
||||
if (result.stdout && result.stdout.trim()) {
|
||||
try {
|
||||
json = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
const tests = [
|
||||
{
|
||||
name: 'skeleton: gate script responds with placeholder envelope when given fixture input',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-skeleton',
|
||||
currentTask: 'task-5',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}\n${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output\nstdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.gate !== 'approved_plan_continuity') {
|
||||
throw new Error(`expected gate=approved_plan_continuity, got ${JSON.stringify(result.json.gate)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'continuity: fails when task is complete, next action is known, no dispatch receipt exists, and closure is not in an allowed terminal state',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-missing-dispatch',
|
||||
currentTask: 'task-6',
|
||||
taskState: 'complete',
|
||||
nextDerivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-7',
|
||||
},
|
||||
replyClosureState: 'completed',
|
||||
dispatchReceipt: null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}\n${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output\nstdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.ok !== false) {
|
||||
throw new Error(`expected continuity failure ok=false, got ${JSON.stringify(result.json)}`);
|
||||
}
|
||||
|
||||
if (result.json.verdict !== 'continuity_failure') {
|
||||
throw new Error(`expected verdict=continuity_failure, got ${JSON.stringify(result.json.verdict)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'continuity: fails when planner returns derivedAction without any bound dispatch receipt',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-derived-action-without-bound-dispatch',
|
||||
currentTask: 'task-6b',
|
||||
taskState: 'complete',
|
||||
derivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-7b',
|
||||
},
|
||||
replyClosureState: 'completed',
|
||||
dispatchReceipt: null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}
|
||||
${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output
|
||||
stdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.ok !== false) {
|
||||
throw new Error(`expected continuity failure ok=false for derivedAction without dispatch receipt, got ${JSON.stringify(result.json)}`);
|
||||
}
|
||||
|
||||
if (result.json.verdict !== 'continuity_failure') {
|
||||
throw new Error(`expected verdict=continuity_failure for derivedAction without dispatch receipt, got ${JSON.stringify(result.json.verdict)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'continuity: passes when task is complete, next action is known, and a dispatch receipt already exists',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-existing-dispatch',
|
||||
currentTask: 'task-6',
|
||||
taskState: 'complete',
|
||||
nextDerivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-7',
|
||||
},
|
||||
replyClosureState: 'completed',
|
||||
dispatchReceipt: {
|
||||
planId: 'plan-existing-dispatch',
|
||||
currentTask: 'task-6',
|
||||
nextDerivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-7',
|
||||
},
|
||||
dispatchedAt: '2026-04-24T11:55:00+08:00',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}
|
||||
${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output
|
||||
stdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.ok !== true) {
|
||||
throw new Error(`expected continuity pass ok=true when dispatch receipt exists, got ${JSON.stringify(result.json)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'continuity: passes when planner returns derivedAction and a bound dispatch receipt already exists',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-derived-action-with-bound-dispatch',
|
||||
currentTask: 'task-6c',
|
||||
taskState: 'complete',
|
||||
derivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-7c',
|
||||
},
|
||||
replyClosureState: 'completed',
|
||||
dispatchReceipt: {
|
||||
planId: 'plan-derived-action-with-bound-dispatch',
|
||||
currentTask: 'task-6c',
|
||||
derivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-7c',
|
||||
},
|
||||
dispatchedAt: '2026-04-24T12:05:00+08:00',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}
|
||||
${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output
|
||||
stdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.ok !== true) {
|
||||
throw new Error(`expected continuity pass ok=true when derivedAction has bound dispatch receipt, got ${JSON.stringify(result.json)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'continuity: passes when task is complete, next action is known, no dispatch receipt exists, and closure is waiting_user',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-waiting-user-closure',
|
||||
currentTask: 'task-8',
|
||||
taskState: 'complete',
|
||||
nextDerivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-9',
|
||||
},
|
||||
replyClosureState: 'waiting_user',
|
||||
dispatchReceipt: null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}
|
||||
${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output
|
||||
stdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.ok !== true) {
|
||||
throw new Error(`expected continuity pass ok=true when closure is waiting_user, got ${JSON.stringify(result.json)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'continuity: passes when task is complete, next action is known, no dispatch receipt exists, and closure is pending_verification',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-pending-verification-closure',
|
||||
currentTask: 'task-8b',
|
||||
taskState: 'complete',
|
||||
nextDerivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-9',
|
||||
},
|
||||
replyClosureState: 'pending_verification',
|
||||
dispatchReceipt: null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}
|
||||
${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output
|
||||
stdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.ok !== true) {
|
||||
throw new Error(`expected continuity pass ok=true when closure is pending_verification, got ${JSON.stringify(result.json)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'continuity: passes when task is complete, next action is known, no dispatch receipt exists, and closure is blocked',
|
||||
run() {
|
||||
const fixture = createFixture({
|
||||
'input.json': {
|
||||
planId: 'plan-blocked-closure',
|
||||
currentTask: 'task-9',
|
||||
taskState: 'complete',
|
||||
nextDerivedAction: {
|
||||
type: 'message_subagent',
|
||||
task: 'continue with task-10',
|
||||
},
|
||||
replyClosureState: 'blocked',
|
||||
dispatchReceipt: null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runGate({
|
||||
args: ['--compact', '--input', fixture.path('input.json')],
|
||||
});
|
||||
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new Error(`expected controlled execution, got status=${result.status}
|
||||
${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
if (!result.json || typeof result.json !== 'object') {
|
||||
throw new Error(`expected JSON output
|
||||
stdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.json.ok !== true) {
|
||||
throw new Error(`expected continuity pass ok=true when closure is blocked, got ${JSON.stringify(result.json)}`);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const results = [];
|
||||
let failed = false;
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
test.run();
|
||||
results.push({ test: test.name, ok: true });
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
results.push({
|
||||
test: test.name,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: tests.length,
|
||||
passed: results.filter((entry) => entry.ok).length,
|
||||
failed: results.filter((entry) => !entry.ok).length,
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ summary, results }, null, 2)}\n`);
|
||||
|
||||
if (failed) process.exit(1);
|
||||
245
scripts/test_subagent_delivery_watchdog.mjs
Normal file
245
scripts/test_subagent_delivery_watchdog.mjs
Normal file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
||||
const WATCHDOG_SCRIPT = path.join(ROOT_DIR, 'scripts', 'subagent_delivery_watchdog.mjs');
|
||||
|
||||
function createFixtureRunner() {
|
||||
const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'subagent-watchdog-test-'));
|
||||
|
||||
function writeFixture(name, content) {
|
||||
const fixturePath = path.join(fixtureRoot, name);
|
||||
const body = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
writeFileSync(fixturePath, body);
|
||||
return fixturePath;
|
||||
}
|
||||
|
||||
function runWatchdog(args = [], options = {}) {
|
||||
const result = spawnSync(process.execPath, [WATCHDOG_SCRIPT, ...args], {
|
||||
cwd: ROOT_DIR,
|
||||
encoding: 'utf8',
|
||||
...options,
|
||||
});
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
error: result.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
fixtureRoot,
|
||||
writeFixture,
|
||||
runWatchdog,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
const tests = [];
|
||||
|
||||
function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
|
||||
function printResult(prefix, name, detail = '') {
|
||||
const suffix = detail ? ` ${detail}` : '';
|
||||
process.stdout.write(`${prefix} ${name}${suffix}\n`);
|
||||
}
|
||||
|
||||
test('fixture runner can invoke watchdog skeleton with a generated input file', () => {
|
||||
const runner = createFixtureRunner();
|
||||
|
||||
try {
|
||||
const inputPath = runner.writeFixture('dispatch.json', {
|
||||
runId: 'fixture-run-001',
|
||||
childSessionKey: 'session:test',
|
||||
});
|
||||
|
||||
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, '');
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.tool, 'subagent_delivery_watchdog');
|
||||
assert.equal(payload.result.status, 'not_implemented');
|
||||
assert.equal(payload.input.path, inputPath);
|
||||
assert.equal(payload.input.exists, true);
|
||||
} finally {
|
||||
runner.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('watchdog reports active before SLA when dispatch exists and no completion receipt has arrived yet', () => {
|
||||
const runner = createFixtureRunner();
|
||||
|
||||
try {
|
||||
const inputPath = runner.writeFixture('dispatch-before-sla.json', {
|
||||
runId: 'fixture-run-active-before-sla',
|
||||
childSessionKey: 'session:active-before-sla',
|
||||
dispatchAt: '2026-04-24T10:00:00.000Z',
|
||||
expectedBy: '2026-04-24T10:10:00.000Z',
|
||||
currentTime: '2026-04-24T10:05:00.000Z',
|
||||
});
|
||||
|
||||
const result = runner.runWatchdog(['--compact', '--input', inputPath]);
|
||||
|
||||
assert.equal(result.status, 0, `expected zero exit status, got ${result.status}
|
||||
${result.stderr}`);
|
||||
assert.equal(result.stderr, '');
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.input.path, inputPath);
|
||||
assert.equal(payload.input.exists, true);
|
||||
assert.equal(payload.result.status, 'active');
|
||||
} finally {
|
||||
runner.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('watchdog reports suspect delivery failure after SLA when dispatch exists and no completion receipt has arrived yet', () => {
|
||||
const runner = createFixtureRunner();
|
||||
|
||||
try {
|
||||
const inputPath = runner.writeFixture('dispatch-beyond-sla.json', {
|
||||
runId: 'fixture-run-suspect-delivery-failure',
|
||||
childSessionKey: 'session:suspect-delivery-failure',
|
||||
dispatchAt: '2026-04-24T10:00:00.000Z',
|
||||
expectedBy: '2026-04-24T10:10:00.000Z',
|
||||
currentTime: '2026-04-24T10:15:00.000Z',
|
||||
});
|
||||
|
||||
const result = runner.runWatchdog(['--compact', '--input', inputPath]);
|
||||
|
||||
assert.equal(result.status, 0, `expected zero exit status, got ${result.status}
|
||||
${result.stderr}`);
|
||||
assert.equal(result.stderr, '');
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.input.path, inputPath);
|
||||
assert.equal(payload.input.exists, true);
|
||||
assert.equal(payload.result.status, 'suspect_delivery_failure');
|
||||
} finally {
|
||||
runner.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('watchdog reports completed when dispatch exists and completion receipt has arrived', () => {
|
||||
const runner = createFixtureRunner();
|
||||
|
||||
try {
|
||||
const inputPath = runner.writeFixture('dispatch-completed.json', {
|
||||
runId: 'fixture-run-completed',
|
||||
childSessionKey: 'session:completed',
|
||||
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',
|
||||
});
|
||||
|
||||
const result = runner.runWatchdog(['--compact', '--input', inputPath]);
|
||||
|
||||
assert.equal(result.status, 0, `expected zero exit status, got ${result.status}
|
||||
${result.stderr}`);
|
||||
assert.equal(result.stderr, '');
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.input.path, inputPath);
|
||||
assert.equal(payload.input.exists, true);
|
||||
assert.equal(payload.result.status, 'completed');
|
||||
} finally {
|
||||
runner.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('watchdog reports done but not forwarded when child run is marked done without a main-thread completion receipt', () => {
|
||||
const runner = createFixtureRunner();
|
||||
|
||||
try {
|
||||
const inputPath = runner.writeFixture('dispatch-done-not-forwarded.json', {
|
||||
runId: 'fixture-run-done-not-forwarded',
|
||||
childSessionKey: 'session:done-not-forwarded',
|
||||
dispatchAt: '2026-04-24T10:00:00.000Z',
|
||||
expectedBy: '2026-04-24T10:10:00.000Z',
|
||||
currentTime: '2026-04-24T10:05:00.000Z',
|
||||
childRunStatus: 'done',
|
||||
});
|
||||
|
||||
const result = runner.runWatchdog(['--compact', '--input', inputPath]);
|
||||
|
||||
assert.equal(result.status, 0, `expected zero exit status, got ${result.status}
|
||||
${result.stderr}`);
|
||||
assert.equal(result.stderr, '');
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.input.path, inputPath);
|
||||
assert.equal(payload.input.exists, true);
|
||||
assert.equal(payload.result.status, 'done_but_not_forwarded');
|
||||
} finally {
|
||||
runner.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture runner exposes missing-input behavior for future fail-first cases', () => {
|
||||
const runner = createFixtureRunner();
|
||||
|
||||
try {
|
||||
const missingPath = path.join(runner.fixtureRoot, 'missing.json');
|
||||
const result = runner.runWatchdog(['--compact', '--input', missingPath]);
|
||||
|
||||
assert.equal(result.status, 0, `expected zero exit status, got ${result.status}\n${result.stderr}`);
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.input.path, missingPath);
|
||||
assert.equal(payload.input.exists, false);
|
||||
assert.equal(payload.result.status, 'not_implemented');
|
||||
} finally {
|
||||
runner.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
function main() {
|
||||
let passed = 0;
|
||||
|
||||
for (const { name, fn } of tests) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
printResult('PASS', name);
|
||||
} catch (error) {
|
||||
printResult('FAIL', name, error instanceof Error ? `- ${error.message}` : `- ${String(error)}`);
|
||||
if (error instanceof Error && error.stack) {
|
||||
process.stderr.write(`${error.stack}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const failed = tests.length - passed;
|
||||
process.stdout.write(`\nSummary: ${passed} passed, ${failed} failed, ${tests.length} total\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user