agentxchain 2.156.0 → 2.158.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/bin/agentxchain.js +18 -0
- package/package.json +1 -1
- package/src/commands/attention.js +66 -0
- package/src/commands/ship-status.js +104 -0
- package/src/lib/adapters/local-cli-adapter.js +37 -0
- package/src/lib/ci-reporter.js +24 -12
- package/src/lib/continuous-run.js +66 -4
- package/src/lib/dispatch-bundle.js +12 -0
- package/src/lib/human-attention.js +381 -0
- package/src/lib/report.js +15 -0
- package/src/lib/ship-status.js +545 -0
- package/src/lib/stream-json-cost-parser.js +169 -0
- package/src/lib/turn-result-validator.js +61 -5
package/bin/agentxchain.js
CHANGED
|
@@ -124,6 +124,8 @@ import { benchmarkCommand } from '../src/commands/benchmark.js';
|
|
|
124
124
|
import { benchmarkWorkloadsCommand } from '../src/commands/benchmark-workloads.js';
|
|
125
125
|
import { historyCommand } from '../src/commands/history.js';
|
|
126
126
|
import { decisionsCommand } from '../src/commands/decisions.js';
|
|
127
|
+
import { shipStatusCommand } from '../src/commands/ship-status.js';
|
|
128
|
+
import { attentionCommand } from '../src/commands/attention.js';
|
|
127
129
|
import { diffCommand } from '../src/commands/diff.js';
|
|
128
130
|
import { eventsCommand } from '../src/commands/events.js';
|
|
129
131
|
import { connectorCapabilitiesCommand, connectorCheckCommand, connectorValidateCommand } from '../src/commands/connector.js';
|
|
@@ -420,6 +422,22 @@ program
|
|
|
420
422
|
.option('-d, --dir <path>', 'Project directory')
|
|
421
423
|
.action(decisionsCommand);
|
|
422
424
|
|
|
425
|
+
program
|
|
426
|
+
.command('ship-status')
|
|
427
|
+
.description('Evaluate whether the current project is ready to ship')
|
|
428
|
+
.option('-j, --json', 'Machine-readable JSON output')
|
|
429
|
+
.option('-v, --verbose', 'Per-dimension detail breakdown')
|
|
430
|
+
.option('-d, --dir <path>', 'Project directory')
|
|
431
|
+
.action(shipStatusCommand);
|
|
432
|
+
|
|
433
|
+
program
|
|
434
|
+
.command('attention')
|
|
435
|
+
.description('Show the cross-run human-decision queue — what needs you, and nothing else (govern by exception)')
|
|
436
|
+
.option('-j, --json', 'Machine-readable JSON output (full HumanAttentionReport)')
|
|
437
|
+
.option('-a, --all', 'Include informational/non-blocking items (e.g. pending approved intents)')
|
|
438
|
+
.option('-d, --dir <path>', 'Project directory')
|
|
439
|
+
.action(attentionCommand);
|
|
440
|
+
|
|
423
441
|
program
|
|
424
442
|
.command('diff <left_ref> <right_ref>')
|
|
425
443
|
.description('Compare two recorded governed runs or two export artifacts')
|
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: agentxchain attention
|
|
3
|
+
*
|
|
4
|
+
* Answers "what needs MY attention right now — and nothing else?" by composing the
|
|
5
|
+
* cross-category human-decision queue into a single HumanAttentionReport. All evaluation
|
|
6
|
+
* logic lives in lib/human-attention.js — this command is presentation only
|
|
7
|
+
* (Architecture Invariant #6).
|
|
8
|
+
*
|
|
9
|
+
* It is a status surface, not a gate: it exits 0 in BOTH the clear and attention states.
|
|
10
|
+
* When the queue is empty it prints "Nothing needs your attention." — the operational
|
|
11
|
+
* proof that the human can step back and let governed autonomy run (VISION.md:51).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { findProjectRoot } from '../lib/config.js';
|
|
17
|
+
import { evaluateHumanAttention } from '../lib/human-attention.js';
|
|
18
|
+
|
|
19
|
+
export async function attentionCommand(opts) {
|
|
20
|
+
const dir = opts.dir ? resolve(opts.dir) : process.cwd();
|
|
21
|
+
const root = findProjectRoot(dir);
|
|
22
|
+
if (!root) {
|
|
23
|
+
console.error(chalk.red('No agentxchain project found. Run "agentxchain init" first.'));
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const report = evaluateHumanAttention(root);
|
|
29
|
+
|
|
30
|
+
if (opts.json) {
|
|
31
|
+
// --json always emits the full, schema-valid HumanAttentionReport; exit 0 in both states.
|
|
32
|
+
console.log(JSON.stringify(report, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (report.overall === 'clear') {
|
|
37
|
+
console.log(chalk.green('Nothing needs your attention.'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default view surfaces blocking + escalation items first; informational pending-intent
|
|
42
|
+
// items are summarized as a count unless --all is passed.
|
|
43
|
+
const visible = opts.all ? report.items : report.items.filter((item) => item.blocking);
|
|
44
|
+
const hidden = report.items.length - visible.length;
|
|
45
|
+
|
|
46
|
+
console.log(
|
|
47
|
+
`\n${chalk.bold('Attention needed')}: ${report.items_count} `
|
|
48
|
+
+ `item${report.items_count === 1 ? '' : 's'} (${report.blocking_count} blocking)`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
visible.forEach((item, idx) => {
|
|
52
|
+
const tag = chalk.cyan(String(item.category).padEnd(16));
|
|
53
|
+
const run = item.run_id ? `${chalk.dim(item.run_id)} ` : '';
|
|
54
|
+
console.log(` [${idx + 1}] ${tag} ${run}${item.summary}`);
|
|
55
|
+
console.log(` ${chalk.dim('→')} ${item.action_hint}`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (hidden > 0) {
|
|
59
|
+
console.log(
|
|
60
|
+
chalk.dim(` (+${hidden} informational item${hidden === 1 ? '' : 's'} — run with --all to show)`),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log('');
|
|
65
|
+
// Status surface, not a gate — no non-zero exit even in the attention state.
|
|
66
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: agentxchain ship-status
|
|
3
|
+
*
|
|
4
|
+
* Answers "is this ready to ship?" by composing five evidence dimensions into a
|
|
5
|
+
* single ShipStatusReport. All evaluation logic lives in lib/ship-status.js — this
|
|
6
|
+
* command is presentation only (Architecture Invariant #5).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { resolve, join } from 'node:path';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { findProjectRoot } from '../lib/config.js';
|
|
13
|
+
import { COORDINATOR_CONFIG_FILE } from '../lib/coordinator-config.js';
|
|
14
|
+
import { evaluateShipStatus, evaluateCoordinatorShipStatus } from '../lib/ship-status.js';
|
|
15
|
+
|
|
16
|
+
function statusIcon(status) {
|
|
17
|
+
if (status === 'pass') return chalk.green('✓');
|
|
18
|
+
if (status === 'fail') return chalk.red('✗');
|
|
19
|
+
return chalk.yellow('?');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function overallLabel(overall) {
|
|
23
|
+
if (overall === 'pass') return chalk.green.bold('YES');
|
|
24
|
+
if (overall === 'fail') return chalk.red.bold('NO');
|
|
25
|
+
return chalk.yellow.bold('PENDING');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function countPassed(report) {
|
|
29
|
+
return (report.dimensions || []).filter((d) => d.status === 'pass').length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function printDimensions(dimensions, indent) {
|
|
33
|
+
for (const dim of dimensions || []) {
|
|
34
|
+
console.log(`${indent}${statusIcon(dim.status)} ${dim.name}: ${dim.detail}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printSingle(report, opts) {
|
|
39
|
+
const passed = countPassed(report);
|
|
40
|
+
const total = (report.dimensions || []).length;
|
|
41
|
+
const blocking = report.blocking_reasons || [];
|
|
42
|
+
const suffix = blocking.length ? `, ${blocking.length} blocking` : '';
|
|
43
|
+
console.log(`\nShip Status: ${overallLabel(report.overall)} (${passed}/${total} dimensions pass${suffix})`);
|
|
44
|
+
|
|
45
|
+
if (opts.verbose) {
|
|
46
|
+
console.log('');
|
|
47
|
+
printDimensions(report.dimensions, ' ');
|
|
48
|
+
} else {
|
|
49
|
+
for (const reason of blocking) {
|
|
50
|
+
console.log(` ${chalk.dim('-')} ${reason}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function shipStatusCommand(opts) {
|
|
57
|
+
const dir = opts.dir ? resolve(opts.dir) : process.cwd();
|
|
58
|
+
const coordinatorConfigPath = join(dir, COORDINATOR_CONFIG_FILE);
|
|
59
|
+
|
|
60
|
+
let report;
|
|
61
|
+
let isCoordinator = false;
|
|
62
|
+
|
|
63
|
+
if (existsSync(coordinatorConfigPath)) {
|
|
64
|
+
isCoordinator = true;
|
|
65
|
+
report = evaluateCoordinatorShipStatus(dir);
|
|
66
|
+
} else {
|
|
67
|
+
const root = findProjectRoot(dir);
|
|
68
|
+
if (!root) {
|
|
69
|
+
console.error(chalk.red('No agentxchain project found. Run "agentxchain init" first.'));
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
report = evaluateShipStatus(root);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (opts.json) {
|
|
77
|
+
console.log(JSON.stringify(report, null, 2));
|
|
78
|
+
} else if (isCoordinator) {
|
|
79
|
+
const blocking = report.blocking_repos || [];
|
|
80
|
+
const total = (report.repos || []).length;
|
|
81
|
+
const shippable = total - blocking.length;
|
|
82
|
+
console.log(`\nCoordinator Ship Status: ${overallLabel(report.overall)} (${shippable}/${total} repos shippable)`);
|
|
83
|
+
for (const entry of report.repos || []) {
|
|
84
|
+
const passed = countPassed(entry.ship_status);
|
|
85
|
+
const dimTotal = (entry.ship_status.dimensions || []).length;
|
|
86
|
+
console.log(`\n ${chalk.bold(entry.repo_id)}: ${overallLabel(entry.ship_status.overall)} (${passed}/${dimTotal})`);
|
|
87
|
+
if (opts.verbose) {
|
|
88
|
+
printDimensions(entry.ship_status.dimensions, ' ');
|
|
89
|
+
} else {
|
|
90
|
+
for (const reason of entry.ship_status.blocking_reasons || []) {
|
|
91
|
+
console.log(` ${chalk.dim('-')} ${reason}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
console.log('');
|
|
96
|
+
} else {
|
|
97
|
+
printSingle(report, opts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Non-zero exit when not shippable (useful for CI gating).
|
|
101
|
+
if (report.overall === 'fail') {
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
parseRateLimitResetMs,
|
|
45
45
|
resolveClaudeCompatibleNodeBinary,
|
|
46
46
|
} from '../claude-local-auth.js';
|
|
47
|
+
import {
|
|
48
|
+
createStreamJsonCostParser,
|
|
49
|
+
buildCostFromStreamJson,
|
|
50
|
+
} from '../stream-json-cost-parser.js';
|
|
47
51
|
|
|
48
52
|
const DIAGNOSTIC_ENV_KEYS = [
|
|
49
53
|
'PATH',
|
|
@@ -147,6 +151,16 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
147
151
|
};
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
// Create stream-json cost parser for Claude runtimes with stream-json output.
|
|
155
|
+
// Only Claude runtimes emit parseable NDJSON with result events; other runtimes
|
|
156
|
+
// (Codex, Cursor, Windsurf, OpenCode) have different stdout formats.
|
|
157
|
+
const tokens = [command, ...args].filter((t) => typeof t === 'string');
|
|
158
|
+
const usesStreamJson = tokens.includes('--output-format=stream-json')
|
|
159
|
+
|| (tokens.includes('--output-format') && tokens[tokens.indexOf('--output-format') + 1] === 'stream-json');
|
|
160
|
+
const costParser = isClaudeLocalCliRuntime(runtime) && usesStreamJson
|
|
161
|
+
? createStreamJsonCostParser()
|
|
162
|
+
: null;
|
|
163
|
+
|
|
150
164
|
// Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
|
|
151
165
|
const timeoutMs = options.dispatchTimeoutMs != null
|
|
152
166
|
? options.dispatchTimeoutMs
|
|
@@ -425,6 +439,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
425
439
|
stdoutBytes += Buffer.byteLength(text);
|
|
426
440
|
recordFirstOutput('stdout');
|
|
427
441
|
logs.push(text);
|
|
442
|
+
if (costParser) costParser.push(text);
|
|
428
443
|
if (onStdout) onStdout(text);
|
|
429
444
|
});
|
|
430
445
|
}
|
|
@@ -558,6 +573,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
558
573
|
appendDiagnostic(logs, 'process_exit', exitDiagnostic);
|
|
559
574
|
|
|
560
575
|
if (hasResult) {
|
|
576
|
+
// Enrich cost from stream-json if available (best-effort, never blocks settle)
|
|
577
|
+
if (costParser) {
|
|
578
|
+
try {
|
|
579
|
+
const parsedCost = costParser.getResult();
|
|
580
|
+
if (parsedCost) {
|
|
581
|
+
const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
|
|
582
|
+
const raw = readFileSync(stagingPath, 'utf8');
|
|
583
|
+
const turnResult = JSON.parse(raw);
|
|
584
|
+
turnResult.cost = buildCostFromStreamJson(parsedCost, config);
|
|
585
|
+
writeFileSync(stagingPath, JSON.stringify(turnResult, null, 2) + '\n');
|
|
586
|
+
appendDiagnostic(logs, 'stream_json_cost_enriched', {
|
|
587
|
+
input_tokens: parsedCost.input_tokens,
|
|
588
|
+
output_tokens: parsedCost.output_tokens,
|
|
589
|
+
cost_usd: parsedCost.cost_usd,
|
|
590
|
+
model: parsedCost.model,
|
|
591
|
+
computed_usd: turnResult.cost.usd,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
// Cost enrichment is best-effort — if it fails, agent-reported cost is preserved
|
|
596
|
+
}
|
|
597
|
+
}
|
|
561
598
|
settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
|
|
562
599
|
} else if (isClaudeLocalCliRuntime(runtime) && hasClaudeAuthFailureOutput(logs)) {
|
|
563
600
|
const recovery = 'Refresh Claude credentials before resuming: export a valid ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, then run agentxchain step --resume.';
|
package/src/lib/ci-reporter.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { appendFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Normalize gate_summary to [gateName, statusString] entries.
|
|
5
|
+
* Handles both array format ([{ gate_id, status }]) from the real report pipeline
|
|
6
|
+
* and object format ({ gateName: statusString }) from legacy/synthetic fixtures.
|
|
7
|
+
*/
|
|
8
|
+
function normalizeGateEntries(gates) {
|
|
9
|
+
if (Array.isArray(gates)) {
|
|
10
|
+
return gates
|
|
11
|
+
.filter(g => typeof g?.gate_id === 'string' && typeof g?.status === 'string')
|
|
12
|
+
.map(g => [g.gate_id, g.status]);
|
|
13
|
+
}
|
|
14
|
+
if (gates && typeof gates === 'object') {
|
|
15
|
+
return Object.entries(gates).filter(([, v]) => typeof v === 'string');
|
|
16
|
+
}
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
/**
|
|
4
21
|
* Detect CI environment from process.env.
|
|
5
22
|
* @returns {{ provider: string, run_url: string|null, run_id: string|null, ref: string|null, sha: string|null } | null}
|
|
@@ -55,16 +72,12 @@ export function formatGitHubAnnotations(report) {
|
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
// Gate annotations
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
lines.push(`::notice title=Gate ${gateName}::satisfied`);
|
|
65
|
-
} else {
|
|
66
|
-
lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
|
|
67
|
-
}
|
|
75
|
+
for (const [gateName, gateStatus] of normalizeGateEntries(report.subject?.run?.gate_summary)) {
|
|
76
|
+
const normalizedStatus = gateStatus.toLowerCase();
|
|
77
|
+
if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
|
|
78
|
+
lines.push(`::notice title=Gate ${gateName}::satisfied`);
|
|
79
|
+
} else {
|
|
80
|
+
lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
|
|
68
81
|
}
|
|
69
82
|
}
|
|
70
83
|
|
|
@@ -113,7 +126,6 @@ export function writeGitHubOutputVars(report, outputPath) {
|
|
|
113
126
|
*/
|
|
114
127
|
export function formatJUnitXml(report) {
|
|
115
128
|
const run = report.subject?.run || {};
|
|
116
|
-
const gates = run.gate_summary || {};
|
|
117
129
|
const turns = run.turns || [];
|
|
118
130
|
|
|
119
131
|
// Escape XML special characters
|
|
@@ -124,7 +136,7 @@ export function formatJUnitXml(report) {
|
|
|
124
136
|
.replace(/"/g, '"');
|
|
125
137
|
|
|
126
138
|
// Build gate test cases
|
|
127
|
-
const gateEntries =
|
|
139
|
+
const gateEntries = normalizeGateEntries(run.gate_summary);
|
|
128
140
|
const gateFailures = gateEntries.filter(([, status]) => {
|
|
129
141
|
const s = status.toLowerCase();
|
|
130
142
|
return s !== 'satisfied' && s !== 'pass' && s !== 'passed';
|
|
@@ -1141,10 +1141,27 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
1143
|
const errorClass = result.error_class || 'reconcile_refused';
|
|
1144
|
+
// RB-14: `reconcile-state --accept-operator-head` runs the SAME safety check, so it
|
|
1145
|
+
// cannot clear an unsafe-edit refusal — recommending it for governance_state_modified
|
|
1146
|
+
// or critical_artifact_deleted is a dead end. Give the recovery that actually applies.
|
|
1147
|
+
const offendingCommit = result.offending_commit || result.commit || null;
|
|
1148
|
+
const offendingPath = result.offending_path || result.path || null;
|
|
1149
|
+
let recoveryAction;
|
|
1150
|
+
let recoveryDetailTail;
|
|
1151
|
+
if (errorClass === 'governance_state_modified') {
|
|
1152
|
+
recoveryAction = offendingCommit ? `git revert ${offendingCommit.slice(0, 12)}` : 'git revert <offending-commit>';
|
|
1153
|
+
recoveryDetailTail = `${offendingPath || 'A commit'} edits governed state under .agentxchain/ directly, which the anti-tamper guard never auto-accepts (reconcile-state --accept-operator-head enforces the same check). Revert it (${recoveryAction}) to restore the governed lineage, or restart from an explicit prior checkpoint.`;
|
|
1154
|
+
} else if (errorClass === 'critical_artifact_deleted') {
|
|
1155
|
+
recoveryAction = (offendingCommit && offendingPath) ? `git checkout ${offendingCommit.slice(0, 12)}^ -- ${offendingPath}` : 'git checkout <commit>^ -- <deleted-path>';
|
|
1156
|
+
recoveryDetailTail = `Critical governed evidence ${offendingPath || ''} was deleted; restore it (${recoveryAction}), then resume.`;
|
|
1157
|
+
} else {
|
|
1158
|
+
recoveryAction = 'agentxchain reconcile-state --accept-operator-head';
|
|
1159
|
+
recoveryDetailTail = `Run: ${recoveryAction} once the unsafe changes are resolved, or revert them.`;
|
|
1160
|
+
}
|
|
1144
1161
|
const detailLines = [
|
|
1145
1162
|
`Operator-commit auto-reconcile refused (${errorClass}).`,
|
|
1146
1163
|
result.error || 'Unsafe operator commits detected; manual recovery required.',
|
|
1147
|
-
|
|
1164
|
+
recoveryDetailTail,
|
|
1148
1165
|
];
|
|
1149
1166
|
const detail = detailLines.join(' ');
|
|
1150
1167
|
|
|
@@ -1164,7 +1181,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1164
1181
|
...((state.blocked_reason || {}).recovery || {}),
|
|
1165
1182
|
typed_reason: 'operator_commit_reconcile_refused',
|
|
1166
1183
|
owner: 'human',
|
|
1167
|
-
recovery_action:
|
|
1184
|
+
recovery_action: recoveryAction,
|
|
1168
1185
|
turn_retained: false,
|
|
1169
1186
|
detail,
|
|
1170
1187
|
},
|
|
@@ -1196,7 +1213,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1196
1213
|
status: 'blocked',
|
|
1197
1214
|
action: 'operator_commit_reconcile_refused',
|
|
1198
1215
|
run_id: session.current_run_id,
|
|
1199
|
-
recovery_action:
|
|
1216
|
+
recovery_action: recoveryAction,
|
|
1200
1217
|
blocked_category: 'operator_commit_reconcile_refused',
|
|
1201
1218
|
error_class: errorClass,
|
|
1202
1219
|
};
|
|
@@ -2579,10 +2596,47 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
2579
2596
|
process.on('SIGINT', sigHandler);
|
|
2580
2597
|
|
|
2581
2598
|
try {
|
|
2599
|
+
// RB-13: circuit-breaker for the recover-and-continue path. A deterministic,
|
|
2600
|
+
// recurring failure (e.g. an unclean baseline) must escalate, not busy-loop
|
|
2601
|
+
// forever — without this, the same start error spun 582 times in seconds.
|
|
2602
|
+
// Track consecutive identical recoveries; halt and escalate after a cap, and
|
|
2603
|
+
// back off between attempts so even allowed retries can't tight-loop.
|
|
2604
|
+
let consecutiveRecoveries = 0;
|
|
2605
|
+
let lastRecoveryReason = null;
|
|
2606
|
+
const maxConsecutiveRecoveries = Math.max(1, contOpts.maxConsecutiveRecoveries ?? 3);
|
|
2607
|
+
|
|
2582
2608
|
while (!stopping) {
|
|
2583
2609
|
const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
|
|
2584
2610
|
|
|
2585
2611
|
if (step.status === 'failed' && isGovernedRunStillActiveForSession(root, context.config, session)) {
|
|
2612
|
+
const recoveryReason = step.stop_reason || step.action || 'unknown';
|
|
2613
|
+
if (recoveryReason === lastRecoveryReason) {
|
|
2614
|
+
consecutiveRecoveries += 1;
|
|
2615
|
+
} else {
|
|
2616
|
+
consecutiveRecoveries = 1;
|
|
2617
|
+
lastRecoveryReason = recoveryReason;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
if (consecutiveRecoveries >= maxConsecutiveRecoveries) {
|
|
2621
|
+
// Circuit open: the same recoverable failure keeps recurring without
|
|
2622
|
+
// progress. Escalate to a human instead of spinning the loop.
|
|
2623
|
+
session.status = 'blocked';
|
|
2624
|
+
writeContinuousSession(root, session);
|
|
2625
|
+
emitRunEvent(root, 'continuous_recovery_circuit_open', {
|
|
2626
|
+
run_id: session.current_run_id || null,
|
|
2627
|
+
phase: null,
|
|
2628
|
+
status: 'blocked',
|
|
2629
|
+
payload: {
|
|
2630
|
+
session_id: session.session_id,
|
|
2631
|
+
failed_action: step.action || null,
|
|
2632
|
+
failed_reason: recoveryReason,
|
|
2633
|
+
consecutive_recoveries: consecutiveRecoveries,
|
|
2634
|
+
},
|
|
2635
|
+
});
|
|
2636
|
+
log(`Continuous loop halted (circuit-breaker): the same recoverable failure recurred ${consecutiveRecoveries} times with no progress — escalating to a human instead of spinning. Reason: ${recoveryReason}`);
|
|
2637
|
+
return { exitCode: 1, session };
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2586
2640
|
session.status = 'running';
|
|
2587
2641
|
writeContinuousSession(root, session);
|
|
2588
2642
|
emitRunEvent(root, 'session_failed_recovered_active_run', {
|
|
@@ -2593,12 +2647,20 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
2593
2647
|
session_id: session.session_id,
|
|
2594
2648
|
failed_action: step.action || null,
|
|
2595
2649
|
failed_reason: step.stop_reason || null,
|
|
2650
|
+
consecutive_recoveries: consecutiveRecoveries,
|
|
2596
2651
|
},
|
|
2597
2652
|
});
|
|
2598
|
-
log(
|
|
2653
|
+
log(`Session failure recovered - governed run still active, continuing (recovery ${consecutiveRecoveries}/${maxConsecutiveRecoveries}).`);
|
|
2654
|
+
// Backoff before retrying so a recurring failure cannot busy-loop.
|
|
2655
|
+
const recoveryBackoffMs = Math.max(1000, (contOpts.cooldownSeconds ?? 5) * 1000);
|
|
2656
|
+
await new Promise((resolve) => setTimeout(resolve, recoveryBackoffMs));
|
|
2599
2657
|
continue;
|
|
2600
2658
|
}
|
|
2601
2659
|
|
|
2660
|
+
// A non-recovery step means the loop made forward progress — reset the breaker.
|
|
2661
|
+
consecutiveRecoveries = 0;
|
|
2662
|
+
lastRecoveryReason = null;
|
|
2663
|
+
|
|
2602
2664
|
// Terminal states
|
|
2603
2665
|
if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted' || step.status === 'session_budget') {
|
|
2604
2666
|
const terminalMessage = describeContinuousTerminalStep(step, contOpts);
|
|
@@ -471,6 +471,18 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
471
471
|
lines.push('');
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
// Single-shot execution model — prevents ghost turns from async / background-wait patterns.
|
|
475
|
+
lines.push('## Single-Shot Execution (read carefully)');
|
|
476
|
+
lines.push('');
|
|
477
|
+
lines.push('You run as a **single, non-interactive `--print` subprocess**. You get exactly ONE invocation with no "later": there is no async wake-up, no second message, and nothing will notify you after you stop responding. The moment you end your turn the subprocess exits — if you have not written your turn result by then, the entire turn is **discarded as a ghost** (no result, wasted run).');
|
|
478
|
+
lines.push('');
|
|
479
|
+
lines.push('Therefore:');
|
|
480
|
+
lines.push('- Do ALL work **synchronously** within this one turn. Run every command to completion in the foreground and read its output inline before continuing.');
|
|
481
|
+
lines.push('- **NEVER** start a background job and then wait for it. Do not use `ScheduleWakeup`; do not start a `Monitor` or `tail -f` and wait for events; do not background a process with `&` and poll for a notification; do not defer any conclusion to "after the suite/build finishes". You will not be re-invoked to finish.');
|
|
482
|
+
lines.push('- If you need a long command or test suite result, run it **blocking in the foreground** (e.g. `npm test`) and wait for it to return in-line — the per-turn timeout is generous, so budget your time for it.');
|
|
483
|
+
lines.push('- Writing the structured turn result below is your **final action**: gather every piece of evidence first, then write the result before you finish.');
|
|
484
|
+
lines.push('');
|
|
485
|
+
|
|
474
486
|
// Output format with complete JSON template
|
|
475
487
|
lines.push('## Required Output');
|
|
476
488
|
lines.push('');
|