agentxchain 2.146.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/package.json +1 -1
- package/scripts/publish-npm.sh +16 -0
- package/scripts/sync-homebrew.sh +14 -1
- package/scripts/verify-post-publish.sh +55 -4
- package/src/commands/reissue-turn.js +16 -0
- package/src/commands/reject-turn.js +14 -1
- package/src/commands/restart.js +15 -0
- package/src/commands/resume.js +61 -66
- package/src/commands/run.js +67 -10
- package/src/commands/schedule.js +34 -7
- package/src/commands/status.js +20 -0
- package/src/commands/step.js +100 -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/continuous-run.js +71 -6
- package/src/lib/dispatch-bundle.js +1 -1
- package/src/lib/dispatch-progress.js +5 -3
- package/src/lib/governed-state.js +224 -13
- package/src/lib/intake.js +10 -1
- package/src/lib/normalized-config.js +51 -1
- package/src/lib/recent-event-summary.js +11 -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 +218 -90
- package/src/lib/turn-result-shape.js +38 -0
- package/src/lib/turn-result-validator.js +4 -1
package/src/commands/status.js
CHANGED
|
@@ -382,6 +382,16 @@ function renderGovernedStatus(context, opts) {
|
|
|
382
382
|
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reject-turn --turn ${turn.turn_id}`)} — reject and retry`);
|
|
383
383
|
console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
|
|
384
384
|
}
|
|
385
|
+
if (turn.status === 'failed_start') {
|
|
386
|
+
console.log(` ${chalk.dim('Reason:')} ${turn.failed_start_reason || 'no_subprocess_output'}`);
|
|
387
|
+
const recover = turn.recovery_command || `agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost`;
|
|
388
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
|
|
389
|
+
}
|
|
390
|
+
if (turn.status === 'stalled') {
|
|
391
|
+
console.log(` ${chalk.dim('Reason:')} ${turn.stalled_reason || 'no_output_within_threshold'}`);
|
|
392
|
+
const recover = turn.recovery_command || `agentxchain reissue-turn --turn ${turn.turn_id} --reason stale`;
|
|
393
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
|
|
394
|
+
}
|
|
385
395
|
}
|
|
386
396
|
} else if (singleActiveTurn) {
|
|
387
397
|
console.log(` ${chalk.dim('Turn:')} ${singleActiveTurn.turn_id}`);
|
|
@@ -432,6 +442,16 @@ function renderGovernedStatus(context, opts) {
|
|
|
432
442
|
console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
|
|
433
443
|
console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
|
|
434
444
|
}
|
|
445
|
+
if (singleActiveTurn.status === 'failed_start') {
|
|
446
|
+
console.log(` ${chalk.dim('Reason:')} ${singleActiveTurn.failed_start_reason || 'no_subprocess_output'}`);
|
|
447
|
+
const recover = singleActiveTurn.recovery_command || `agentxchain reissue-turn --turn ${singleActiveTurn.turn_id} --reason ghost`;
|
|
448
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
|
|
449
|
+
}
|
|
450
|
+
if (singleActiveTurn.status === 'stalled') {
|
|
451
|
+
console.log(` ${chalk.dim('Reason:')} ${singleActiveTurn.stalled_reason || 'no_output_within_threshold'}`);
|
|
452
|
+
const recover = singleActiveTurn.recovery_command || `agentxchain reissue-turn --turn ${singleActiveTurn.turn_id} --reason stale`;
|
|
453
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
|
|
454
|
+
}
|
|
435
455
|
} else {
|
|
436
456
|
console.log(` ${chalk.dim('Turn:')} ${chalk.yellow('No active turn')}`);
|
|
437
457
|
}
|
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();
|
|
@@ -260,39 +262,14 @@ export async function stepCommand(opts) {
|
|
|
260
262
|
printDispatchBundleWarnings(bundleResult);
|
|
261
263
|
}
|
|
262
264
|
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
|
|
272
|
-
process.exit(1);
|
|
273
|
-
}
|
|
274
|
-
state = reactivated.state;
|
|
275
|
-
if (reactivated.migration_notice) {
|
|
276
|
-
console.log(chalk.yellow(reactivated.migration_notice));
|
|
277
|
-
}
|
|
278
|
-
if (reactivated.phantom_notice) {
|
|
279
|
-
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
280
|
-
}
|
|
281
|
-
skipAssignment = true;
|
|
282
|
-
|
|
283
|
-
// BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
|
|
284
|
-
refreshTurnBaselineSnapshot(root, pausedTurn.turn_id);
|
|
285
|
-
state = JSON.parse(readFileSync(join(root, '.agentxchain/state.json'), 'utf8'));
|
|
286
|
-
|
|
287
|
-
const bundleResult = writeDispatchBundle(root, state, config);
|
|
288
|
-
if (!bundleResult.ok) {
|
|
289
|
-
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
290
|
-
process.exit(1);
|
|
291
|
-
}
|
|
292
|
-
bundleWritten = true;
|
|
293
|
-
printDispatchBundleWarnings(bundleResult);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
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.
|
|
296
273
|
|
|
297
274
|
// idle → initialize run
|
|
298
275
|
if (!skipAssignment && state.status === 'idle' && !state.run_id) {
|
|
@@ -344,6 +321,27 @@ export async function stepCommand(opts) {
|
|
|
344
321
|
}
|
|
345
322
|
}
|
|
346
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
|
+
|
|
347
345
|
// Assign the turn
|
|
348
346
|
if (!skipAssignment) {
|
|
349
347
|
const roleId = resolveTargetRole(opts, state, config);
|
|
@@ -448,6 +446,10 @@ export async function stepCommand(opts) {
|
|
|
448
446
|
console.log(chalk.red(`Failed to finalize dispatch manifest: ${manifestResult.error}`));
|
|
449
447
|
process.exit(1);
|
|
450
448
|
}
|
|
449
|
+
const dispatched = transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
|
|
450
|
+
if (dispatched.ok) {
|
|
451
|
+
state = dispatched.state;
|
|
452
|
+
}
|
|
451
453
|
}
|
|
452
454
|
|
|
453
455
|
const controller = new AbortController();
|
|
@@ -456,6 +458,13 @@ export async function stepCommand(opts) {
|
|
|
456
458
|
});
|
|
457
459
|
|
|
458
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
|
+
}
|
|
459
468
|
console.log(chalk.cyan(`Dispatching to API proxy: ${runtime?.provider || '(unknown)'} / ${runtime?.model || '(unknown)'}`));
|
|
460
469
|
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
|
|
461
470
|
|
|
@@ -535,6 +544,13 @@ export async function stepCommand(opts) {
|
|
|
535
544
|
}
|
|
536
545
|
console.log('');
|
|
537
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
|
+
}
|
|
538
554
|
const mcpTransport = resolveMcpTransport(runtime);
|
|
539
555
|
console.log(chalk.cyan(`Dispatching to MCP ${mcpTransport}: ${describeMcpRuntimeTarget(runtime)}`));
|
|
540
556
|
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase} Tool: ${runtime?.tool_name || 'agentxchain_turn'}`));
|
|
@@ -589,6 +605,13 @@ export async function stepCommand(opts) {
|
|
|
589
605
|
console.log(chalk.green(`MCP tool completed${mcpResult.toolName ? ` (${mcpResult.toolName})` : ''}. Staged result detected.`));
|
|
590
606
|
console.log('');
|
|
591
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
|
+
}
|
|
592
615
|
console.log(chalk.cyan(`Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
|
|
593
616
|
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
|
|
594
617
|
|
|
@@ -667,8 +690,25 @@ export async function stepCommand(opts) {
|
|
|
667
690
|
|
|
668
691
|
// BUG-6: stream subprocess output by default (--stream or --verbose), suppress with --quiet
|
|
669
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
|
+
};
|
|
670
708
|
const cliResult = await dispatchLocalCli(root, state, config, {
|
|
671
709
|
signal: controller.signal,
|
|
710
|
+
onSpawnAttached: ({ pid, at }) => ensureStartingState(pid, at),
|
|
711
|
+
onFirstOutput: ({ at, stream }) => ensureRunningState(stream, at),
|
|
672
712
|
onStdout: shouldStream ? (text) => process.stdout.write(chalk.dim(text)) : undefined,
|
|
673
713
|
onStderr: shouldStream ? (text) => process.stderr.write(chalk.yellow(text)) : undefined,
|
|
674
714
|
verifyManifest: true,
|
|
@@ -714,6 +754,28 @@ export async function stepCommand(opts) {
|
|
|
714
754
|
process.exit(1);
|
|
715
755
|
}
|
|
716
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
|
+
|
|
717
779
|
if (!cliResult.ok) {
|
|
718
780
|
const blocked = markRunBlocked(root, {
|
|
719
781
|
blockedOn: `dispatch:${cliResult.exitCode != null ? `exit-${cliResult.exitCode}` : 'subprocess_failed'}`,
|
|
@@ -744,6 +806,10 @@ export async function stepCommand(opts) {
|
|
|
744
806
|
process.exit(1);
|
|
745
807
|
}
|
|
746
808
|
|
|
809
|
+
if (!runningMarked) {
|
|
810
|
+
ensureRunningState('staged_result', cliResult.firstOutputAt || new Date().toISOString());
|
|
811
|
+
}
|
|
812
|
+
|
|
747
813
|
console.log(chalk.green('Subprocess completed. Staged result detected.'));
|
|
748
814
|
console.log('');
|
|
749
815
|
} else {
|
|
@@ -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) {
|