agentxchain 2.145.0 → 2.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/app.js +3 -0
- package/dashboard/components/notifications.js +127 -0
- package/dashboard/index.html +1 -0
- package/package.json +1 -1
- package/scripts/publish-npm.sh +16 -0
- package/scripts/release-downstream-truth.sh +16 -8
- package/scripts/sync-homebrew.sh +14 -1
- package/scripts/verify-post-publish.sh +55 -4
- package/src/commands/init.js +66 -31
- package/src/commands/reissue-turn.js +16 -0
- package/src/commands/reject-turn.js +14 -1
- package/src/commands/restart.js +33 -3
- package/src/commands/resume.js +78 -66
- package/src/commands/run.js +67 -10
- package/src/commands/schedule.js +34 -7
- package/src/commands/status.js +38 -5
- package/src/commands/step.js +117 -34
- package/src/lib/adapters/api-proxy-adapter.js +8 -0
- package/src/lib/adapters/local-cli-adapter.js +131 -13
- package/src/lib/adapters/manual-adapter.js +9 -10
- package/src/lib/adapters/mcp-adapter.js +3 -5
- package/src/lib/adapters/remote-agent-adapter.js +3 -5
- package/src/lib/config.js +4 -1
- package/src/lib/continuous-run.js +71 -6
- package/src/lib/dashboard/actions.js +9 -3
- package/src/lib/dashboard/bridge-server.js +11 -0
- package/src/lib/dashboard/notifications-reader.js +91 -0
- package/src/lib/dashboard/state-reader.js +16 -4
- package/src/lib/dispatch-bundle.js +1 -1
- package/src/lib/dispatch-progress.js +5 -3
- package/src/lib/governed-state.js +355 -13
- package/src/lib/intake.js +10 -1
- package/src/lib/normalized-config.js +51 -1
- package/src/lib/recent-event-summary.js +12 -0
- package/src/lib/run-events.js +4 -0
- package/src/lib/run-loop.js +67 -2
- package/src/lib/runner-interface.js +1 -0
- package/src/lib/schema.js +7 -0
- package/src/lib/schemas/agentxchain-config.schema.json +15 -1
- package/src/lib/staged-result-proof.js +43 -0
- package/src/lib/stale-turn-watchdog.js +308 -34
- package/src/lib/turn-result-shape.js +38 -0
- package/src/lib/turn-result-validator.js +4 -1
package/src/commands/step.js
CHANGED
|
@@ -35,7 +35,9 @@ import {
|
|
|
35
35
|
getActiveTurnCount,
|
|
36
36
|
getActiveTurns,
|
|
37
37
|
reactivateGovernedRun,
|
|
38
|
+
reconcilePhaseAdvanceBeforeDispatch,
|
|
38
39
|
refreshTurnBaselineSnapshot,
|
|
40
|
+
transitionActiveTurnLifecycle,
|
|
39
41
|
STATE_PATH,
|
|
40
42
|
} from '../lib/governed-state.js';
|
|
41
43
|
import { getMaxConcurrentTurns } from '../lib/normalized-config.js';
|
|
@@ -70,7 +72,7 @@ import { resolveGovernedRole } from '../lib/role-resolution.js';
|
|
|
70
72
|
import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
|
|
71
73
|
import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
|
|
72
74
|
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
73
|
-
import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
75
|
+
import { failTurnStartup, reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
74
76
|
|
|
75
77
|
export async function stepCommand(opts) {
|
|
76
78
|
const context = loadProjectContext();
|
|
@@ -97,6 +99,10 @@ export async function stepCommand(opts) {
|
|
|
97
99
|
|
|
98
100
|
const staleReconciliation = reconcileStaleTurns(root, state, config);
|
|
99
101
|
state = staleReconciliation.state || state;
|
|
102
|
+
if (staleReconciliation.ghost_turns.length > 0) {
|
|
103
|
+
printGhostTurnRecovery(staleReconciliation.ghost_turns);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
100
106
|
if (staleReconciliation.stale_turns.length > 0) {
|
|
101
107
|
printStaleTurnRecovery(staleReconciliation.stale_turns);
|
|
102
108
|
process.exit(1);
|
|
@@ -256,39 +262,14 @@ export async function stepCommand(opts) {
|
|
|
256
262
|
printDispatchBundleWarnings(bundleResult);
|
|
257
263
|
}
|
|
258
264
|
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
|
|
268
|
-
process.exit(1);
|
|
269
|
-
}
|
|
270
|
-
state = reactivated.state;
|
|
271
|
-
if (reactivated.migration_notice) {
|
|
272
|
-
console.log(chalk.yellow(reactivated.migration_notice));
|
|
273
|
-
}
|
|
274
|
-
if (reactivated.phantom_notice) {
|
|
275
|
-
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
276
|
-
}
|
|
277
|
-
skipAssignment = true;
|
|
278
|
-
|
|
279
|
-
// BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
|
|
280
|
-
refreshTurnBaselineSnapshot(root, pausedTurn.turn_id);
|
|
281
|
-
state = JSON.parse(readFileSync(join(root, '.agentxchain/state.json'), 'utf8'));
|
|
282
|
-
|
|
283
|
-
const bundleResult = writeDispatchBundle(root, state, config);
|
|
284
|
-
if (!bundleResult.ok) {
|
|
285
|
-
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
286
|
-
process.exit(1);
|
|
287
|
-
}
|
|
288
|
-
bundleWritten = true;
|
|
289
|
-
printDispatchBundleWarnings(bundleResult);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
265
|
+
// Removed (Turn 25): the `paused + failed/retrying retained turn → re-dispatch`
|
|
266
|
+
// branch is unreachable under the current schema. See the matching deletion in
|
|
267
|
+
// `cli/src/commands/resume.js` for the full citation chain (schema.js:184 +
|
|
268
|
+
// governed-state.js:2191-2204 + the line-187 short-circuit above). The reachable
|
|
269
|
+
// retained-turn re-dispatch path for `step --resume` is the `state.status ===
|
|
270
|
+
// 'blocked' && activeCount > 0` branch at line 193 above. Per
|
|
271
|
+
// `DEC-UNREACHABLE-BRANCH-COVERAGE-001`, dead branches are removed once the
|
|
272
|
+
// schema citation + migration citation are documented.
|
|
292
273
|
|
|
293
274
|
// idle → initialize run
|
|
294
275
|
if (!skipAssignment && state.status === 'idle' && !state.run_id) {
|
|
@@ -340,6 +321,27 @@ export async function stepCommand(opts) {
|
|
|
340
321
|
}
|
|
341
322
|
}
|
|
342
323
|
|
|
324
|
+
if (!skipAssignment) {
|
|
325
|
+
const phaseReconciliation = reconcilePhaseAdvanceBeforeDispatch(root, config, state);
|
|
326
|
+
if (!phaseReconciliation.ok && !phaseReconciliation.state) {
|
|
327
|
+
console.log(chalk.red(`Failed to reconcile phase gate before dispatch: ${phaseReconciliation.error}`));
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
state = phaseReconciliation.state || state;
|
|
331
|
+
if (phaseReconciliation.advanced) {
|
|
332
|
+
console.log(chalk.green(`Advanced phase before dispatch: ${phaseReconciliation.from_phase} → ${phaseReconciliation.to_phase}`));
|
|
333
|
+
}
|
|
334
|
+
if (state.pending_phase_transition || state.pending_run_completion) {
|
|
335
|
+
evaluateApprovalSlaReminders(root, config, state);
|
|
336
|
+
printRecoverySummary(state, 'This run is awaiting approval.', config);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
if (state.status === 'blocked') {
|
|
340
|
+
printRecoverySummary(state, 'This run is blocked.', config);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
343
345
|
// Assign the turn
|
|
344
346
|
if (!skipAssignment) {
|
|
345
347
|
const roleId = resolveTargetRole(opts, state, config);
|
|
@@ -444,6 +446,10 @@ export async function stepCommand(opts) {
|
|
|
444
446
|
console.log(chalk.red(`Failed to finalize dispatch manifest: ${manifestResult.error}`));
|
|
445
447
|
process.exit(1);
|
|
446
448
|
}
|
|
449
|
+
const dispatched = transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
|
|
450
|
+
if (dispatched.ok) {
|
|
451
|
+
state = dispatched.state;
|
|
452
|
+
}
|
|
447
453
|
}
|
|
448
454
|
|
|
449
455
|
const controller = new AbortController();
|
|
@@ -452,6 +458,13 @@ export async function stepCommand(opts) {
|
|
|
452
458
|
});
|
|
453
459
|
|
|
454
460
|
if (runtimeType === 'api_proxy') {
|
|
461
|
+
const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', {
|
|
462
|
+
stream: 'request',
|
|
463
|
+
at: new Date().toISOString(),
|
|
464
|
+
});
|
|
465
|
+
if (running.ok) {
|
|
466
|
+
state = running.state;
|
|
467
|
+
}
|
|
455
468
|
console.log(chalk.cyan(`Dispatching to API proxy: ${runtime?.provider || '(unknown)'} / ${runtime?.model || '(unknown)'}`));
|
|
456
469
|
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
|
|
457
470
|
|
|
@@ -531,6 +544,13 @@ export async function stepCommand(opts) {
|
|
|
531
544
|
}
|
|
532
545
|
console.log('');
|
|
533
546
|
} else if (runtimeType === 'mcp') {
|
|
547
|
+
const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', {
|
|
548
|
+
stream: 'request',
|
|
549
|
+
at: new Date().toISOString(),
|
|
550
|
+
});
|
|
551
|
+
if (running.ok) {
|
|
552
|
+
state = running.state;
|
|
553
|
+
}
|
|
534
554
|
const mcpTransport = resolveMcpTransport(runtime);
|
|
535
555
|
console.log(chalk.cyan(`Dispatching to MCP ${mcpTransport}: ${describeMcpRuntimeTarget(runtime)}`));
|
|
536
556
|
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase} Tool: ${runtime?.tool_name || 'agentxchain_turn'}`));
|
|
@@ -585,6 +605,13 @@ export async function stepCommand(opts) {
|
|
|
585
605
|
console.log(chalk.green(`MCP tool completed${mcpResult.toolName ? ` (${mcpResult.toolName})` : ''}. Staged result detected.`));
|
|
586
606
|
console.log('');
|
|
587
607
|
} else if (runtimeType === 'remote_agent') {
|
|
608
|
+
const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', {
|
|
609
|
+
stream: 'request',
|
|
610
|
+
at: new Date().toISOString(),
|
|
611
|
+
});
|
|
612
|
+
if (running.ok) {
|
|
613
|
+
state = running.state;
|
|
614
|
+
}
|
|
588
615
|
console.log(chalk.cyan(`Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
|
|
589
616
|
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
|
|
590
617
|
|
|
@@ -663,8 +690,25 @@ export async function stepCommand(opts) {
|
|
|
663
690
|
|
|
664
691
|
// BUG-6: stream subprocess output by default (--stream or --verbose), suppress with --quiet
|
|
665
692
|
const shouldStream = opts.stream || opts.verbose || false;
|
|
693
|
+
let runningMarked = false;
|
|
694
|
+
const ensureStartingState = (pid = null, at = new Date().toISOString()) => {
|
|
695
|
+
const starting = transitionActiveTurnLifecycle(root, turn.turn_id, 'starting', { pid, at });
|
|
696
|
+
if (starting.ok) {
|
|
697
|
+
state = starting.state;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
const ensureRunningState = (stream = 'stdout', at = new Date().toISOString()) => {
|
|
701
|
+
if (runningMarked) return;
|
|
702
|
+
runningMarked = true;
|
|
703
|
+
const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', { stream, at });
|
|
704
|
+
if (running.ok) {
|
|
705
|
+
state = running.state;
|
|
706
|
+
}
|
|
707
|
+
};
|
|
666
708
|
const cliResult = await dispatchLocalCli(root, state, config, {
|
|
667
709
|
signal: controller.signal,
|
|
710
|
+
onSpawnAttached: ({ pid, at }) => ensureStartingState(pid, at),
|
|
711
|
+
onFirstOutput: ({ at, stream }) => ensureRunningState(stream, at),
|
|
668
712
|
onStdout: shouldStream ? (text) => process.stdout.write(chalk.dim(text)) : undefined,
|
|
669
713
|
onStderr: shouldStream ? (text) => process.stderr.write(chalk.yellow(text)) : undefined,
|
|
670
714
|
verifyManifest: true,
|
|
@@ -710,6 +754,28 @@ export async function stepCommand(opts) {
|
|
|
710
754
|
process.exit(1);
|
|
711
755
|
}
|
|
712
756
|
|
|
757
|
+
if (cliResult.startupFailure) {
|
|
758
|
+
const freshState = loadProjectState(root, config) || state;
|
|
759
|
+
const failed = failTurnStartup(root, freshState, config, turn.turn_id, {
|
|
760
|
+
failure_type: cliResult.startupFailureType || 'no_subprocess_output',
|
|
761
|
+
threshold_ms: config?.run_loop?.startup_watchdog_ms ?? 30_000,
|
|
762
|
+
running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
|
|
763
|
+
? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
|
|
764
|
+
: 0,
|
|
765
|
+
recommendation: `Turn ${turn.turn_id} failed to start within the startup watchdog window. Run \`agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost\` to recover.`,
|
|
766
|
+
});
|
|
767
|
+
if (failed.ok) {
|
|
768
|
+
state = failed.state;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
console.log('');
|
|
772
|
+
console.log(chalk.red(`Turn startup failed: ${cliResult.error}`));
|
|
773
|
+
console.log(chalk.dim('The turn was retained as failed_start. You can:'));
|
|
774
|
+
console.log(chalk.dim(` - Reissue immediately: agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost`));
|
|
775
|
+
console.log(chalk.dim(' - Inspect status: agentxchain status'));
|
|
776
|
+
process.exit(1);
|
|
777
|
+
}
|
|
778
|
+
|
|
713
779
|
if (!cliResult.ok) {
|
|
714
780
|
const blocked = markRunBlocked(root, {
|
|
715
781
|
blockedOn: `dispatch:${cliResult.exitCode != null ? `exit-${cliResult.exitCode}` : 'subprocess_failed'}`,
|
|
@@ -740,6 +806,10 @@ export async function stepCommand(opts) {
|
|
|
740
806
|
process.exit(1);
|
|
741
807
|
}
|
|
742
808
|
|
|
809
|
+
if (!runningMarked) {
|
|
810
|
+
ensureRunningState('staged_result', cliResult.firstOutputAt || new Date().toISOString());
|
|
811
|
+
}
|
|
812
|
+
|
|
743
813
|
console.log(chalk.green('Subprocess completed. Staged result detected.'));
|
|
744
814
|
console.log('');
|
|
745
815
|
} else {
|
|
@@ -909,6 +979,19 @@ export async function stepCommand(opts) {
|
|
|
909
979
|
}
|
|
910
980
|
}
|
|
911
981
|
|
|
982
|
+
function printGhostTurnRecovery(ghostTurns) {
|
|
983
|
+
console.log(chalk.red.bold('Ghost turn detected — subprocess never started.'));
|
|
984
|
+
console.log('');
|
|
985
|
+
for (const ghost of ghostTurns) {
|
|
986
|
+
const secs = Math.floor(ghost.running_ms / 1000);
|
|
987
|
+
console.log(` Turn: ${ghost.turn_id} (${ghost.role})`);
|
|
988
|
+
console.log(` Runtime: ${ghost.runtime_id}`);
|
|
989
|
+
console.log(` Age: ${secs}s with no subprocess output`);
|
|
990
|
+
console.log(` Recover: ${chalk.cyan(`agentxchain reissue-turn --turn ${ghost.turn_id} --reason ghost`)}`);
|
|
991
|
+
console.log('');
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
912
995
|
function printStaleTurnRecovery(staleTurns) {
|
|
913
996
|
console.log(chalk.red.bold('Stale turn detected.'));
|
|
914
997
|
console.log('');
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
|
|
30
30
|
import { join } from 'path';
|
|
31
31
|
import { evaluateTokenBudget, SYSTEM_PROMPT, SEPARATOR } from '../token-budget.js';
|
|
32
|
+
import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
|
|
32
33
|
import {
|
|
33
34
|
getDispatchApiRequestPath,
|
|
34
35
|
getDispatchContextPath,
|
|
@@ -1072,6 +1073,13 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
|
|
|
1072
1073
|
turnResult.cost = { ...aggregateUsage };
|
|
1073
1074
|
}
|
|
1074
1075
|
|
|
1076
|
+
if (!hasMinimumTurnResultShape(turnResult)) {
|
|
1077
|
+
return {
|
|
1078
|
+
ok: false,
|
|
1079
|
+
error: 'API response did not contain a valid turn result with the minimum governed turn-result fields',
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1075
1083
|
// Stage the turn result
|
|
1076
1084
|
try {
|
|
1077
1085
|
writeFileSync(
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { spawn } from 'child_process';
|
|
21
|
-
import { existsSync, readFileSync,
|
|
21
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
22
22
|
import { join } from 'path';
|
|
23
23
|
import {
|
|
24
24
|
getDispatchContextPath,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
getTurnStagingResultPath,
|
|
30
30
|
} from '../turn-paths.js';
|
|
31
31
|
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
32
|
+
import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Launch a local CLI subprocess for a governed turn.
|
|
@@ -37,7 +38,7 @@ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
|
37
38
|
* passes them as the prompt to the configured CLI command.
|
|
38
39
|
*
|
|
39
40
|
* @param {string} root - project root directory
|
|
40
|
-
* @param {object} state - current governed state (must
|
|
41
|
+
* @param {object} state - current governed state (must expose an active turn via active_turns; current_turn is a non-enumerable compatibility alias re-attached on load, not a persisted schema field)
|
|
41
42
|
* @param {object} config - normalized config
|
|
42
43
|
* @param {object} [options]
|
|
43
44
|
* @param {AbortSignal} [options.signal] - abort signal for cancellation
|
|
@@ -48,7 +49,15 @@ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
|
48
49
|
* @returns {Promise<{ ok: boolean, exitCode?: number, timedOut?: boolean, aborted?: boolean, error?: string, logs?: string[] }>}
|
|
49
50
|
*/
|
|
50
51
|
export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
51
|
-
const {
|
|
52
|
+
const {
|
|
53
|
+
signal,
|
|
54
|
+
onStdout,
|
|
55
|
+
onStderr,
|
|
56
|
+
onSpawnAttached,
|
|
57
|
+
onFirstOutput,
|
|
58
|
+
startupWatchdogMs = config?.run_loop?.startup_watchdog_ms ?? 30_000,
|
|
59
|
+
turnId,
|
|
60
|
+
} = options;
|
|
52
61
|
|
|
53
62
|
const turn = resolveTargetTurn(state, turnId);
|
|
54
63
|
if (!turn) {
|
|
@@ -118,17 +127,74 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
118
127
|
env: { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id },
|
|
119
128
|
});
|
|
120
129
|
} catch (err) {
|
|
121
|
-
resolve({
|
|
130
|
+
resolve({
|
|
131
|
+
ok: false,
|
|
132
|
+
startupFailure: true,
|
|
133
|
+
startupFailureType: 'runtime_spawn_failed',
|
|
134
|
+
error: `Failed to spawn "${command}": ${err.message}`,
|
|
135
|
+
logs,
|
|
136
|
+
});
|
|
122
137
|
return;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
140
|
let settled = false;
|
|
141
|
+
let firstOutputAt = null;
|
|
142
|
+
let spawnConfirmedAt = null;
|
|
143
|
+
let startupWatchdog = null;
|
|
144
|
+
let startupTimedOut = false;
|
|
145
|
+
let startupFailureType = null;
|
|
146
|
+
|
|
126
147
|
const settle = (result) => {
|
|
127
148
|
if (settled) return;
|
|
128
149
|
settled = true;
|
|
129
150
|
resolve(result);
|
|
130
151
|
};
|
|
131
152
|
|
|
153
|
+
const clearStartupWatchdog = () => {
|
|
154
|
+
if (startupWatchdog) {
|
|
155
|
+
clearTimeout(startupWatchdog);
|
|
156
|
+
startupWatchdog = null;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const armStartupWatchdog = () => {
|
|
161
|
+
if (startupWatchdog || !(startupWatchdogMs > 0 && Number.isFinite(startupWatchdogMs))) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
startupWatchdog = setTimeout(() => {
|
|
165
|
+
if (firstOutputAt || isStagedResultReady(join(root, getTurnStagingResultPath(turn.turn_id)))) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
startupTimedOut = true;
|
|
169
|
+
startupFailureType = 'no_subprocess_output';
|
|
170
|
+
logs.push(`[adapter] Startup watchdog fired after ${Math.round(startupWatchdogMs / 1000)}s with no output.`);
|
|
171
|
+
try {
|
|
172
|
+
child.kill('SIGTERM');
|
|
173
|
+
} catch {}
|
|
174
|
+
}, startupWatchdogMs);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const recordFirstOutput = (stream) => {
|
|
178
|
+
if (firstOutputAt) return;
|
|
179
|
+
firstOutputAt = new Date().toISOString();
|
|
180
|
+
clearStartupWatchdog();
|
|
181
|
+
if (onFirstOutput) {
|
|
182
|
+
try {
|
|
183
|
+
onFirstOutput({ pid: child.pid ?? null, at: firstOutputAt, stream });
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
child.once('spawn', () => {
|
|
189
|
+
spawnConfirmedAt = new Date().toISOString();
|
|
190
|
+
if (onSpawnAttached) {
|
|
191
|
+
try {
|
|
192
|
+
onSpawnAttached({ pid: child.pid ?? null, at: spawnConfirmedAt });
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
armStartupWatchdog();
|
|
196
|
+
});
|
|
197
|
+
|
|
132
198
|
// Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
|
|
133
199
|
if (child.stdin) {
|
|
134
200
|
try {
|
|
@@ -143,6 +209,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
143
209
|
if (child.stdout) {
|
|
144
210
|
child.stdout.on('data', (chunk) => {
|
|
145
211
|
const text = chunk.toString();
|
|
212
|
+
recordFirstOutput('stdout');
|
|
146
213
|
logs.push(text);
|
|
147
214
|
if (onStdout) onStdout(text);
|
|
148
215
|
});
|
|
@@ -151,6 +218,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
151
218
|
if (child.stderr) {
|
|
152
219
|
child.stderr.on('data', (chunk) => {
|
|
153
220
|
const text = chunk.toString();
|
|
221
|
+
recordFirstOutput('stderr');
|
|
154
222
|
logs.push('[stderr] ' + text);
|
|
155
223
|
if (onStderr) onStderr(text);
|
|
156
224
|
});
|
|
@@ -180,6 +248,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
180
248
|
// Abort signal handling
|
|
181
249
|
const onAbort = () => {
|
|
182
250
|
logs.push('[adapter] Abort signal received. Sending SIGTERM.');
|
|
251
|
+
clearStartupWatchdog();
|
|
183
252
|
clearTimeout(timeoutHandle);
|
|
184
253
|
clearTimeout(sigkillHandle);
|
|
185
254
|
try {
|
|
@@ -197,6 +266,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
197
266
|
|
|
198
267
|
// Process exit
|
|
199
268
|
child.on('close', (exitCode, killSignal) => {
|
|
269
|
+
clearStartupWatchdog();
|
|
200
270
|
clearTimeout(timeoutHandle);
|
|
201
271
|
clearTimeout(sigkillHandle);
|
|
202
272
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
@@ -210,17 +280,59 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
210
280
|
|
|
211
281
|
// Check if staged result was written (regardless of exit code)
|
|
212
282
|
const hasResult = isStagedResultReady(join(root, getTurnStagingResultPath(turn.turn_id)));
|
|
283
|
+
if (hasResult && !firstOutputAt) {
|
|
284
|
+
recordFirstOutput('staged_result');
|
|
285
|
+
}
|
|
213
286
|
|
|
214
287
|
if (hasResult) {
|
|
215
|
-
settle({ ok: true, exitCode, timedOut: false, aborted: false, logs });
|
|
288
|
+
settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
|
|
289
|
+
} else if (startupTimedOut) {
|
|
290
|
+
settle({
|
|
291
|
+
ok: false,
|
|
292
|
+
exitCode,
|
|
293
|
+
timedOut: false,
|
|
294
|
+
aborted: false,
|
|
295
|
+
startupFailure: true,
|
|
296
|
+
startupFailureType: startupFailureType || 'no_subprocess_output',
|
|
297
|
+
startupWatchdogMs,
|
|
298
|
+
firstOutputAt,
|
|
299
|
+
error: `Subprocess produced no output within ${Math.round(startupWatchdogMs / 1000)}s and did not stage a turn result.`,
|
|
300
|
+
logs,
|
|
301
|
+
});
|
|
302
|
+
} else if (!spawnConfirmedAt) {
|
|
303
|
+
settle({
|
|
304
|
+
ok: false,
|
|
305
|
+
exitCode,
|
|
306
|
+
timedOut: false,
|
|
307
|
+
aborted: false,
|
|
308
|
+
startupFailure: true,
|
|
309
|
+
startupFailureType: 'runtime_spawn_failed',
|
|
310
|
+
firstOutputAt,
|
|
311
|
+
error: `Subprocess exited (code ${exitCode}) before reporting a successful spawn or staging a turn result.`,
|
|
312
|
+
logs,
|
|
313
|
+
});
|
|
216
314
|
} else if (timedOut) {
|
|
217
315
|
settle({ ok: false, exitCode, timedOut: true, aborted: false, error: 'Turn timed out without producing a staged result.', logs });
|
|
316
|
+
} else if (!firstOutputAt) {
|
|
317
|
+
settle({
|
|
318
|
+
ok: false,
|
|
319
|
+
exitCode,
|
|
320
|
+
timedOut: false,
|
|
321
|
+
aborted: false,
|
|
322
|
+
startupFailure: true,
|
|
323
|
+
startupFailureType: 'no_subprocess_output',
|
|
324
|
+
startupWatchdogMs,
|
|
325
|
+
firstOutputAt,
|
|
326
|
+
error: `Subprocess exited (code ${exitCode}) before producing output or staging a turn result.`,
|
|
327
|
+
logs,
|
|
328
|
+
});
|
|
218
329
|
} else {
|
|
219
330
|
settle({
|
|
220
331
|
ok: false,
|
|
221
332
|
exitCode,
|
|
222
333
|
timedOut: false,
|
|
223
334
|
aborted: false,
|
|
335
|
+
firstOutputAt,
|
|
224
336
|
error: `Subprocess exited (code ${exitCode}) without writing a staged turn result to ${getTurnStagingResultPath(turn.turn_id)}.`,
|
|
225
337
|
logs,
|
|
226
338
|
});
|
|
@@ -228,10 +340,18 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
228
340
|
});
|
|
229
341
|
|
|
230
342
|
child.on('error', (err) => {
|
|
343
|
+
clearStartupWatchdog();
|
|
231
344
|
clearTimeout(timeoutHandle);
|
|
232
345
|
clearTimeout(sigkillHandle);
|
|
233
346
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
234
|
-
settle({
|
|
347
|
+
settle({
|
|
348
|
+
ok: false,
|
|
349
|
+
startupFailure: !firstOutputAt,
|
|
350
|
+
startupFailureType: !firstOutputAt ? 'runtime_spawn_failed' : null,
|
|
351
|
+
firstOutputAt,
|
|
352
|
+
error: `Subprocess error: ${err.message}`,
|
|
353
|
+
logs,
|
|
354
|
+
});
|
|
235
355
|
});
|
|
236
356
|
});
|
|
237
357
|
}
|
|
@@ -322,15 +442,13 @@ function resolvePromptTransport(runtime) {
|
|
|
322
442
|
|
|
323
443
|
/**
|
|
324
444
|
* Check if the staged result file exists and has meaningful content.
|
|
445
|
+
* Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
|
|
446
|
+
* manual adapter, and local-cli adapter all agree on what counts as proof.
|
|
447
|
+
* Per DEC-BUG51-STAGING-PLACEHOLDER-NOT-PROOF-001, placeholders (`{}`, blank,
|
|
448
|
+
* whitespace-only, or `{}\n`) are cleanup artifacts, not evidence.
|
|
325
449
|
*/
|
|
326
450
|
function isStagedResultReady(filePath) {
|
|
327
|
-
|
|
328
|
-
if (!existsSync(filePath)) return false;
|
|
329
|
-
const stat = statSync(filePath);
|
|
330
|
-
return stat.size > 2; // Must be more than just "{}" or empty
|
|
331
|
-
} catch {
|
|
332
|
-
return false;
|
|
333
|
-
}
|
|
451
|
+
return hasMeaningfulStagedResult(filePath);
|
|
334
452
|
}
|
|
335
453
|
|
|
336
454
|
function resolveTargetTurn(state, turnId) {
|
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
* auto-route, and does not pretend to be an orchestrator.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync
|
|
13
|
+
import { existsSync, readFileSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import {
|
|
16
16
|
getDispatchPromptPath,
|
|
17
17
|
getTurnStagingResultPath,
|
|
18
18
|
} from '../turn-paths.js';
|
|
19
|
+
import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Print operator instructions for a manual turn.
|
|
22
23
|
*
|
|
23
|
-
* @param {object} state - current governed state (must
|
|
24
|
+
* @param {object} state - current governed state (must expose an active turn via active_turns; current_turn is a non-enumerable compatibility alias re-attached on load, not a persisted schema field)
|
|
24
25
|
* @param {object} config - normalized config
|
|
25
26
|
* @param {object} [options]
|
|
26
27
|
* @param {string} [options.turnId]
|
|
@@ -282,16 +283,14 @@ export async function waitForStagedResult(root, options = {}) {
|
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
/**
|
|
285
|
-
* Check if the staged result file exists and
|
|
286
|
+
* Check if the staged result file exists and has meaningful content.
|
|
287
|
+
* Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
|
|
288
|
+
* manual adapter, and local-cli adapter all agree on what counts as proof.
|
|
289
|
+
* Per DEC-BUG51-STAGING-PLACEHOLDER-NOT-PROOF-001, placeholders (`{}`, blank,
|
|
290
|
+
* whitespace-only, or `{}\n`) are cleanup artifacts, not evidence.
|
|
286
291
|
*/
|
|
287
292
|
function isStagedResultReady(filePath) {
|
|
288
|
-
|
|
289
|
-
if (!existsSync(filePath)) return false;
|
|
290
|
-
const stat = statSync(filePath);
|
|
291
|
-
return stat.size > 2; // Must be more than just "{}" or empty
|
|
292
|
-
} catch {
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
293
|
+
return hasMeaningfulStagedResult(filePath);
|
|
295
294
|
}
|
|
296
295
|
|
|
297
296
|
/**
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getTurnStagingResultPath,
|
|
13
13
|
} from '../turn-paths.js';
|
|
14
14
|
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
15
|
+
import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_MCP_TOOL_NAME = 'agentxchain_turn';
|
|
17
18
|
export const DEFAULT_MCP_TRANSPORT = 'stdio';
|
|
@@ -237,7 +238,7 @@ export function extractTurnResultFromMcpToolResult(toolResult) {
|
|
|
237
238
|
|
|
238
239
|
for (const block of textBlocks) {
|
|
239
240
|
const parsed = tryParseJson(block.text);
|
|
240
|
-
if (looksLikeTurnResult(parsed)
|
|
241
|
+
if (looksLikeTurnResult(parsed)) {
|
|
241
242
|
return { ok: true, result: parsed };
|
|
242
243
|
}
|
|
243
244
|
}
|
|
@@ -336,10 +337,7 @@ function isPlainObject(value) {
|
|
|
336
337
|
}
|
|
337
338
|
|
|
338
339
|
function looksLikeTurnResult(value) {
|
|
339
|
-
|
|
340
|
-
const hasIdentity = 'run_id' in value || 'turn_id' in value;
|
|
341
|
-
const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
|
|
342
|
-
return hasIdentity && hasLifecycle;
|
|
340
|
+
return hasMinimumTurnResultShape(value);
|
|
343
341
|
}
|
|
344
342
|
|
|
345
343
|
function tryParseJson(value) {
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getTurnStagingResultPath,
|
|
25
25
|
} from '../turn-paths.js';
|
|
26
26
|
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
27
|
+
import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
|
|
27
28
|
|
|
28
29
|
/** Default timeout for remote agent requests (ms). */
|
|
29
30
|
export const DEFAULT_REMOTE_AGENT_TIMEOUT_MS = 120_000;
|
|
@@ -195,7 +196,7 @@ export async function dispatchRemoteAgent(root, state, config, options = {}) {
|
|
|
195
196
|
|
|
196
197
|
// Validate turn result structure (lightweight — full validation happens in the acceptance pipeline)
|
|
197
198
|
if (!looksLikeTurnResult(responseData)) {
|
|
198
|
-
logs.push('[remote] Response missing
|
|
199
|
+
logs.push('[remote] Response missing minimum governed turn-result fields (need schema_version + identity + lifecycle fields)');
|
|
199
200
|
return {
|
|
200
201
|
ok: false,
|
|
201
202
|
error: 'Remote agent response does not contain a valid turn result',
|
|
@@ -230,10 +231,7 @@ export async function dispatchRemoteAgent(root, state, config, options = {}) {
|
|
|
230
231
|
* Full validation happens later via validateStagedTurnResult.
|
|
231
232
|
*/
|
|
232
233
|
function looksLikeTurnResult(value) {
|
|
233
|
-
|
|
234
|
-
const hasIdentity = 'run_id' in value || 'turn_id' in value;
|
|
235
|
-
const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
|
|
236
|
-
return hasIdentity && hasLifecycle;
|
|
234
|
+
return hasMinimumTurnResultShape(value);
|
|
237
235
|
}
|
|
238
236
|
|
|
239
237
|
function resolveTargetTurn(state, turnId) {
|
package/src/lib/config.js
CHANGED
|
@@ -6,6 +6,7 @@ import { safeWriteJson } from './safe-write.js';
|
|
|
6
6
|
import {
|
|
7
7
|
normalizeGovernedStateShape,
|
|
8
8
|
getActiveTurn,
|
|
9
|
+
reconcileApprovalPausesWithConfig,
|
|
9
10
|
reconcileBudgetStatusWithConfig,
|
|
10
11
|
reconcileRecoveryActionsWithConfig,
|
|
11
12
|
} from './governed-state.js';
|
|
@@ -153,11 +154,13 @@ export function loadProjectState(root, config) {
|
|
|
153
154
|
if (config?.protocol_mode === 'governed') {
|
|
154
155
|
const normalized = normalizeGovernedStateShape(stateData);
|
|
155
156
|
stateData = normalized.state;
|
|
157
|
+
const reconciledApprovals = reconcileApprovalPausesWithConfig(stateData, config);
|
|
158
|
+
stateData = reconciledApprovals.state;
|
|
156
159
|
const reconciledBudget = reconcileBudgetStatusWithConfig(stateData, config);
|
|
157
160
|
stateData = reconciledBudget.state;
|
|
158
161
|
const reconciledRecovery = reconcileRecoveryActionsWithConfig(stateData, config);
|
|
159
162
|
stateData = reconciledRecovery.state;
|
|
160
|
-
if (normalized.changed || reconciledBudget.changed || reconciledRecovery.changed) {
|
|
163
|
+
if (normalized.changed || reconciledApprovals.changed || reconciledBudget.changed || reconciledRecovery.changed) {
|
|
161
164
|
safeWriteJson(filePath, stateData);
|
|
162
165
|
}
|
|
163
166
|
}
|