agentxchain 0.8.8 → 2.2.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/README.md +136 -136
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +858 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- package/src/templates/governed/web-app.json +30 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local CLI adapter — subprocess-based agent execution.
|
|
3
|
+
*
|
|
4
|
+
* Per the frozen spec (§20, §37, §38, Session #17):
|
|
5
|
+
* - dispatch: spawns a subprocess with the seed prompt
|
|
6
|
+
* - wait: watches for the staged turn-result.json (file-based, not output-scraping)
|
|
7
|
+
* - collect: reads the staged file (validation happens at the orchestrator level)
|
|
8
|
+
*
|
|
9
|
+
* Prompt transport (Session #17 freeze):
|
|
10
|
+
* - argv: command/args contain {prompt}; adapter substitutes before spawn
|
|
11
|
+
* - stdin: adapter writes prompt to subprocess stdin
|
|
12
|
+
* - dispatch_bundle_only: adapter does NOT deliver prompt; operator reads from disk
|
|
13
|
+
*
|
|
14
|
+
* Key design rules:
|
|
15
|
+
* - Subprocess success !== turn success. Only a validated staged result is turn success.
|
|
16
|
+
* - The adapter does NOT write state, decide transitions, or bypass validation.
|
|
17
|
+
* - Uses the same staging contract as manual mode (.agentxchain/staging/turn-result.json).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'child_process';
|
|
21
|
+
import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import {
|
|
24
|
+
getDispatchContextPath,
|
|
25
|
+
getDispatchLogPath,
|
|
26
|
+
getDispatchPromptPath,
|
|
27
|
+
getDispatchTurnDir,
|
|
28
|
+
getTurnStagingDir,
|
|
29
|
+
getTurnStagingResultPath,
|
|
30
|
+
} from '../turn-paths.js';
|
|
31
|
+
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Launch a local CLI subprocess for a governed turn.
|
|
35
|
+
*
|
|
36
|
+
* Reads the rendered PROMPT.md + CONTEXT.md from the dispatch bundle and
|
|
37
|
+
* passes them as the prompt to the configured CLI command.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} root - project root directory
|
|
40
|
+
* @param {object} state - current governed state (must have current_turn)
|
|
41
|
+
* @param {object} config - normalized config
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {AbortSignal} [options.signal] - abort signal for cancellation
|
|
44
|
+
* @param {function} [options.onStdout] - callback for stdout lines (for logging)
|
|
45
|
+
* @param {function} [options.onStderr] - callback for stderr lines (for logging)
|
|
46
|
+
* @param {boolean} [options.verifyManifest] - require MANIFEST.json and verify before execution
|
|
47
|
+
* @param {boolean} [options.skipManifestVerification] - explicit escape hatch; skip verification even if a manifest exists
|
|
48
|
+
* @returns {Promise<{ ok: boolean, exitCode?: number, timedOut?: boolean, aborted?: boolean, error?: string, logs?: string[] }>}
|
|
49
|
+
*/
|
|
50
|
+
export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
51
|
+
const { signal, onStdout, onStderr, turnId } = options;
|
|
52
|
+
|
|
53
|
+
const turn = resolveTargetTurn(state, turnId);
|
|
54
|
+
if (!turn) {
|
|
55
|
+
return { ok: false, error: 'No active turn in state' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Default policy verifies finalized bundles automatically; step.js still
|
|
59
|
+
// passes verifyManifest: true to require a manifest on governed dispatch.
|
|
60
|
+
const manifestCheck = verifyDispatchManifestForAdapter(root, turn.turn_id, options);
|
|
61
|
+
if (!manifestCheck.ok) {
|
|
62
|
+
return { ok: false, error: `Dispatch manifest verification failed: ${manifestCheck.error}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const runtimeId = turn.runtime_id;
|
|
66
|
+
const runtime = config.runtimes?.[runtimeId];
|
|
67
|
+
if (!runtime) {
|
|
68
|
+
return { ok: false, error: `Runtime "${runtimeId}" not found in config` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Read the dispatch bundle prompt
|
|
72
|
+
const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
|
|
73
|
+
const contextPath = join(root, getDispatchContextPath(turn.turn_id));
|
|
74
|
+
|
|
75
|
+
if (!existsSync(promptPath)) {
|
|
76
|
+
return { ok: false, error: 'Dispatch bundle not found. Run writeDispatchBundle() first.' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const prompt = readFileSync(promptPath, 'utf8');
|
|
80
|
+
const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
|
|
81
|
+
const fullPrompt = prompt + '\n---\n\n' + context;
|
|
82
|
+
|
|
83
|
+
// Resolve command, args, and prompt transport from runtime config
|
|
84
|
+
const { command, args, transport } = resolveCommand(runtime, fullPrompt);
|
|
85
|
+
if (!command) {
|
|
86
|
+
return { ok: false, error: `Cannot resolve CLI command for runtime "${runtimeId}". Expected "command" field in runtime config.` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Compute timeout from deadline or default (20 minutes)
|
|
90
|
+
const timeoutMs = turn.deadline_at
|
|
91
|
+
? Math.max(0, new Date(turn.deadline_at).getTime() - Date.now())
|
|
92
|
+
: 1200000;
|
|
93
|
+
|
|
94
|
+
// Ensure staging directory exists and clear any previous result
|
|
95
|
+
const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
|
|
96
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
97
|
+
const stagingFile = join(root, getTurnStagingResultPath(turn.turn_id));
|
|
98
|
+
try {
|
|
99
|
+
if (existsSync(stagingFile)) {
|
|
100
|
+
writeFileSync(stagingFile, '{}');
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
|
|
104
|
+
// Capture logs for dispatch record
|
|
105
|
+
const logs = [];
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
if (signal?.aborted) {
|
|
109
|
+
resolve({ ok: false, aborted: true, logs });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let child;
|
|
114
|
+
try {
|
|
115
|
+
child = spawn(command, args, {
|
|
116
|
+
cwd: runtime.cwd ? join(root, runtime.cwd) : root,
|
|
117
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
118
|
+
env: { ...process.env },
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
resolve({ ok: false, error: `Failed to spawn "${command}": ${err.message}`, logs });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let settled = false;
|
|
126
|
+
const settle = (result) => {
|
|
127
|
+
if (settled) return;
|
|
128
|
+
settled = true;
|
|
129
|
+
resolve(result);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
|
|
133
|
+
if (child.stdin) {
|
|
134
|
+
try {
|
|
135
|
+
if (transport === 'stdin') {
|
|
136
|
+
child.stdin.write(fullPrompt);
|
|
137
|
+
}
|
|
138
|
+
child.stdin.end();
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Collect stdout/stderr
|
|
143
|
+
if (child.stdout) {
|
|
144
|
+
child.stdout.on('data', (chunk) => {
|
|
145
|
+
const text = chunk.toString();
|
|
146
|
+
logs.push(text);
|
|
147
|
+
if (onStdout) onStdout(text);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (child.stderr) {
|
|
152
|
+
child.stderr.on('data', (chunk) => {
|
|
153
|
+
const text = chunk.toString();
|
|
154
|
+
logs.push('[stderr] ' + text);
|
|
155
|
+
if (onStderr) onStderr(text);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Timeout handling per §20.4
|
|
160
|
+
let timeoutHandle;
|
|
161
|
+
let sigkillHandle;
|
|
162
|
+
|
|
163
|
+
if (timeoutMs > 0 && timeoutMs < Infinity) {
|
|
164
|
+
timeoutHandle = setTimeout(() => {
|
|
165
|
+
logs.push(`[adapter] Turn timed out after ${Math.round(timeoutMs / 1000)}s. Sending SIGTERM.`);
|
|
166
|
+
try {
|
|
167
|
+
child.kill('SIGTERM');
|
|
168
|
+
} catch {}
|
|
169
|
+
|
|
170
|
+
// Grace period: SIGKILL after 10 seconds
|
|
171
|
+
sigkillHandle = setTimeout(() => {
|
|
172
|
+
logs.push('[adapter] Grace period expired. Sending SIGKILL.');
|
|
173
|
+
try {
|
|
174
|
+
child.kill('SIGKILL');
|
|
175
|
+
} catch {}
|
|
176
|
+
}, 10000);
|
|
177
|
+
}, timeoutMs);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Abort signal handling
|
|
181
|
+
const onAbort = () => {
|
|
182
|
+
logs.push('[adapter] Abort signal received. Sending SIGTERM.');
|
|
183
|
+
clearTimeout(timeoutHandle);
|
|
184
|
+
clearTimeout(sigkillHandle);
|
|
185
|
+
try {
|
|
186
|
+
child.kill('SIGTERM');
|
|
187
|
+
} catch {}
|
|
188
|
+
// Give it 5 seconds to exit gracefully
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
191
|
+
}, 5000);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (signal) {
|
|
195
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Process exit
|
|
199
|
+
child.on('close', (exitCode, killSignal) => {
|
|
200
|
+
clearTimeout(timeoutHandle);
|
|
201
|
+
clearTimeout(sigkillHandle);
|
|
202
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
203
|
+
|
|
204
|
+
if (signal?.aborted) {
|
|
205
|
+
settle({ ok: false, aborted: true, exitCode, logs });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const timedOut = killSignal === 'SIGTERM' || killSignal === 'SIGKILL';
|
|
210
|
+
|
|
211
|
+
// Check if staged result was written (regardless of exit code)
|
|
212
|
+
const hasResult = isStagedResultReady(join(root, getTurnStagingResultPath(turn.turn_id)));
|
|
213
|
+
|
|
214
|
+
if (hasResult) {
|
|
215
|
+
settle({ ok: true, exitCode, timedOut: false, aborted: false, logs });
|
|
216
|
+
} else if (timedOut) {
|
|
217
|
+
settle({ ok: false, exitCode, timedOut: true, aborted: false, error: 'Turn timed out without producing a staged result.', logs });
|
|
218
|
+
} else {
|
|
219
|
+
settle({
|
|
220
|
+
ok: false,
|
|
221
|
+
exitCode,
|
|
222
|
+
timedOut: false,
|
|
223
|
+
aborted: false,
|
|
224
|
+
error: `Subprocess exited (code ${exitCode}) without writing a staged turn result to ${getTurnStagingResultPath(turn.turn_id)}.`,
|
|
225
|
+
logs,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
child.on('error', (err) => {
|
|
231
|
+
clearTimeout(timeoutHandle);
|
|
232
|
+
clearTimeout(sigkillHandle);
|
|
233
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
234
|
+
settle({ ok: false, error: `Subprocess error: ${err.message}`, logs });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Save dispatch logs to the dispatch directory for auditability.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} root - project root
|
|
243
|
+
* @param {string} turnId - target turn id
|
|
244
|
+
* @param {string[]} logs - collected log lines
|
|
245
|
+
*/
|
|
246
|
+
export function saveDispatchLogs(root, turnId, logs) {
|
|
247
|
+
const logDir = join(root, getDispatchTurnDir(turnId));
|
|
248
|
+
if (!existsSync(logDir)) return;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
writeFileSync(join(root, getDispatchLogPath(turnId)), logs.join(''));
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Resolve the CLI command and arguments from runtime config.
|
|
259
|
+
*
|
|
260
|
+
* Supports two config shapes:
|
|
261
|
+
* 1. { command: ["claude", "--print", "-p", "{prompt}"] }
|
|
262
|
+
* → command = "claude", args = ["--print", "-p", "<rendered prompt>"]
|
|
263
|
+
*
|
|
264
|
+
* 2. { command: "claude", args: ["--print"] }
|
|
265
|
+
* → command = "claude", args = ["--print"]
|
|
266
|
+
*
|
|
267
|
+
* Prompt injection depends on prompt_transport (Session #17):
|
|
268
|
+
* - "argv" (default when {prompt} present): {prompt} placeholders replaced with prompt text
|
|
269
|
+
* - "stdin": prompt delivered via stdin, no argv substitution
|
|
270
|
+
* - "dispatch_bundle_only": no prompt delivery; subprocess reads from disk
|
|
271
|
+
*
|
|
272
|
+
* Returns { command, args, transport } where transport is the resolved prompt_transport.
|
|
273
|
+
*/
|
|
274
|
+
function resolveCommand(runtime, fullPrompt) {
|
|
275
|
+
if (!runtime.command) return { command: null, args: [], transport: null };
|
|
276
|
+
|
|
277
|
+
const transport = resolvePromptTransport(runtime);
|
|
278
|
+
|
|
279
|
+
// Shape 1: command is an array
|
|
280
|
+
if (Array.isArray(runtime.command)) {
|
|
281
|
+
const [cmd, ...rest] = runtime.command;
|
|
282
|
+
const args = transport === 'argv'
|
|
283
|
+
? rest.map(arg => arg === '{prompt}' ? fullPrompt : arg)
|
|
284
|
+
: rest.filter(arg => arg !== '{prompt}');
|
|
285
|
+
return { command: cmd, args, transport };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Shape 2: command is a string, args is separate
|
|
289
|
+
const cmd = runtime.command;
|
|
290
|
+
const baseArgs = runtime.args || [];
|
|
291
|
+
const args = transport === 'argv'
|
|
292
|
+
? baseArgs.map(a => a === '{prompt}' ? fullPrompt : a)
|
|
293
|
+
: baseArgs.filter(a => a !== '{prompt}');
|
|
294
|
+
return { command: cmd, args, transport };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Resolve the effective prompt transport for a local_cli runtime.
|
|
299
|
+
*
|
|
300
|
+
* Explicit prompt_transport field takes precedence.
|
|
301
|
+
* Fallback: if command/args contain {prompt} → "argv"; otherwise → "dispatch_bundle_only".
|
|
302
|
+
*/
|
|
303
|
+
function resolvePromptTransport(runtime) {
|
|
304
|
+
const VALID_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
|
|
305
|
+
if (runtime.prompt_transport && VALID_TRANSPORTS.includes(runtime.prompt_transport)) {
|
|
306
|
+
return runtime.prompt_transport;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Infer from command shape (backwards compat)
|
|
310
|
+
const commandParts = Array.isArray(runtime.command)
|
|
311
|
+
? runtime.command
|
|
312
|
+
: [runtime.command, ...(runtime.args || [])];
|
|
313
|
+
const hasPlaceholder = commandParts.some(p => typeof p === 'string' && p.includes('{prompt}'));
|
|
314
|
+
return hasPlaceholder ? 'argv' : 'dispatch_bundle_only';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Check if the staged result file exists and has meaningful content.
|
|
319
|
+
*/
|
|
320
|
+
function isStagedResultReady(filePath) {
|
|
321
|
+
try {
|
|
322
|
+
if (!existsSync(filePath)) return false;
|
|
323
|
+
const stat = statSync(filePath);
|
|
324
|
+
return stat.size > 2; // Must be more than just "{}" or empty
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveTargetTurn(state, turnId) {
|
|
331
|
+
if (turnId && state?.active_turns?.[turnId]) {
|
|
332
|
+
return state.active_turns[turnId];
|
|
333
|
+
}
|
|
334
|
+
return state?.current_turn || Object.values(state?.active_turns || {})[0];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export { resolvePromptTransport };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual adapter — the honest fallback.
|
|
3
|
+
*
|
|
4
|
+
* Per the frozen spec (§19, §8.2), the manual adapter:
|
|
5
|
+
* - dispatch: writes a prompt package and prints operator instructions
|
|
6
|
+
* - wait: watches for the staged turn-result.json to appear (fs polling)
|
|
7
|
+
* - collect: reads the staged file (validation happens at the orchestrator level)
|
|
8
|
+
*
|
|
9
|
+
* This adapter is intentionally simple. It does not parse TALK.md, does not
|
|
10
|
+
* auto-route, and does not pretend to be an orchestrator.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import {
|
|
16
|
+
getDispatchPromptPath,
|
|
17
|
+
getTurnStagingResultPath,
|
|
18
|
+
} from '../turn-paths.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Print operator instructions for a manual turn.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} state - current governed state (must have current_turn)
|
|
24
|
+
* @param {object} config - normalized config
|
|
25
|
+
* @param {object} [options]
|
|
26
|
+
* @param {string} [options.turnId]
|
|
27
|
+
*/
|
|
28
|
+
export function printManualDispatchInstructions(state, config, options = {}) {
|
|
29
|
+
const turn = resolveTargetTurn(state, options.turnId);
|
|
30
|
+
const role = config.roles?.[turn.assigned_role];
|
|
31
|
+
const promptPath = getDispatchPromptPath(turn.turn_id);
|
|
32
|
+
const stagingPath = getTurnStagingResultPath(turn.turn_id);
|
|
33
|
+
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push('');
|
|
36
|
+
lines.push(' +---------------------------------------------------------+');
|
|
37
|
+
lines.push(' | MANUAL TURN REQUIRED |');
|
|
38
|
+
lines.push(' | |');
|
|
39
|
+
lines.push(` | Role: ${pad(turn.assigned_role, 46)}|`);
|
|
40
|
+
lines.push(` | Turn: ${pad(turn.turn_id, 46)}|`);
|
|
41
|
+
lines.push(` | Phase: ${pad(state.phase, 46)}|`);
|
|
42
|
+
lines.push(` | Attempt: ${pad(String(turn.attempt), 46)}|`);
|
|
43
|
+
lines.push(' | |');
|
|
44
|
+
lines.push(` | Prompt: ${pad(promptPath, 46)}|`);
|
|
45
|
+
lines.push(` | Result: ${pad(stagingPath, 46)}|`);
|
|
46
|
+
lines.push(' | |');
|
|
47
|
+
lines.push(' | 1. Read the prompt at the path above |');
|
|
48
|
+
lines.push(' | 2. Complete the work described in the prompt |');
|
|
49
|
+
lines.push(' | 3. Write your turn result JSON to the result path |');
|
|
50
|
+
lines.push(' | |');
|
|
51
|
+
lines.push(' | The step command will detect the file and proceed. |');
|
|
52
|
+
lines.push(' +---------------------------------------------------------+');
|
|
53
|
+
lines.push('');
|
|
54
|
+
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Wait for the staged turn result file to appear.
|
|
60
|
+
*
|
|
61
|
+
* Uses polling with a configurable interval. Returns when the file exists
|
|
62
|
+
* and is non-empty, or when the abort signal fires.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} root - project root directory
|
|
65
|
+
* @param {object} [options]
|
|
66
|
+
* @param {number} [options.pollIntervalMs=2000] - polling interval in ms
|
|
67
|
+
* @param {number} [options.timeoutMs=1200000] - max wait time (default: 20 min)
|
|
68
|
+
* @param {AbortSignal} [options.signal] - abort signal to cancel waiting
|
|
69
|
+
* @param {string} [options.turnId] - targeted turn id
|
|
70
|
+
* @returns {Promise<{ found: boolean, timedOut: boolean, aborted: boolean }>}
|
|
71
|
+
*/
|
|
72
|
+
export async function waitForStagedResult(root, options = {}) {
|
|
73
|
+
const {
|
|
74
|
+
pollIntervalMs = 2000,
|
|
75
|
+
timeoutMs = 1200000,
|
|
76
|
+
signal,
|
|
77
|
+
turnId,
|
|
78
|
+
} = options;
|
|
79
|
+
|
|
80
|
+
const stagingFile = join(root, getTurnStagingResultPath(turnId));
|
|
81
|
+
const startTime = Date.now();
|
|
82
|
+
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
// Check for abort signal
|
|
85
|
+
if (signal?.aborted) {
|
|
86
|
+
resolve({ found: false, timedOut: false, aborted: true });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const onAbort = () => {
|
|
91
|
+
clearInterval(timer);
|
|
92
|
+
resolve({ found: false, timedOut: false, aborted: true });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (signal) {
|
|
96
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Initial check
|
|
100
|
+
if (isStagedResultReady(stagingFile)) {
|
|
101
|
+
signal?.removeEventListener('abort', onAbort);
|
|
102
|
+
resolve({ found: true, timedOut: false, aborted: false });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const timer = setInterval(() => {
|
|
107
|
+
// Check timeout
|
|
108
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
109
|
+
clearInterval(timer);
|
|
110
|
+
signal?.removeEventListener('abort', onAbort);
|
|
111
|
+
resolve({ found: false, timedOut: true, aborted: false });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check for file
|
|
116
|
+
if (isStagedResultReady(stagingFile)) {
|
|
117
|
+
clearInterval(timer);
|
|
118
|
+
signal?.removeEventListener('abort', onAbort);
|
|
119
|
+
resolve({ found: true, timedOut: false, aborted: false });
|
|
120
|
+
}
|
|
121
|
+
}, pollIntervalMs);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if the staged result file exists and is non-empty.
|
|
127
|
+
*/
|
|
128
|
+
function isStagedResultReady(filePath) {
|
|
129
|
+
try {
|
|
130
|
+
if (!existsSync(filePath)) return false;
|
|
131
|
+
const stat = statSync(filePath);
|
|
132
|
+
return stat.size > 2; // Must be more than just "{}" or empty
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read the staged result file without validation.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} root - project root directory
|
|
142
|
+
* @param {object} [options]
|
|
143
|
+
* @param {string} [options.turnId]
|
|
144
|
+
* @returns {{ ok: boolean, raw?: string, parsed?: object, error?: string }}
|
|
145
|
+
*/
|
|
146
|
+
export function readStagedResult(root, options = {}) {
|
|
147
|
+
const stagingFile = join(root, getTurnStagingResultPath(options.turnId));
|
|
148
|
+
try {
|
|
149
|
+
const raw = readFileSync(stagingFile, 'utf8');
|
|
150
|
+
const parsed = JSON.parse(raw);
|
|
151
|
+
return { ok: true, raw, parsed };
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return { ok: false, error: err.message };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function pad(str, width) {
|
|
160
|
+
if (str.length >= width) return '...' + str.slice(-(width - 3));
|
|
161
|
+
return str + ' '.repeat(width - str.length);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resolveTargetTurn(state, turnId) {
|
|
165
|
+
if (turnId && state?.active_turns?.[turnId]) {
|
|
166
|
+
return state.active_turns[turnId];
|
|
167
|
+
}
|
|
168
|
+
return state?.current_turn || Object.values(state?.active_turns || {})[0];
|
|
169
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getActiveTurnCount } from './governed-state.js';
|
|
2
|
+
|
|
3
|
+
export function deriveRecoveryDescriptor(state) {
|
|
4
|
+
if (!state || typeof state !== 'object') {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (state.pending_run_completion) {
|
|
9
|
+
return {
|
|
10
|
+
typed_reason: 'pending_run_completion',
|
|
11
|
+
owner: 'human',
|
|
12
|
+
recovery_action: 'agentxchain approve-completion',
|
|
13
|
+
turn_retained: false,
|
|
14
|
+
detail: state.pending_run_completion.gate || null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (state.pending_phase_transition) {
|
|
19
|
+
return {
|
|
20
|
+
typed_reason: 'pending_phase_transition',
|
|
21
|
+
owner: 'human',
|
|
22
|
+
recovery_action: 'agentxchain approve-transition',
|
|
23
|
+
turn_retained: false,
|
|
24
|
+
detail: state.pending_phase_transition.gate || null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const turnRetained = getActiveTurnCount(state) > 0;
|
|
29
|
+
|
|
30
|
+
const persistedRecovery = state.blocked_reason?.recovery;
|
|
31
|
+
if (persistedRecovery && typeof persistedRecovery === 'object') {
|
|
32
|
+
return {
|
|
33
|
+
typed_reason: persistedRecovery.typed_reason || 'unknown_block',
|
|
34
|
+
owner: persistedRecovery.owner || 'human',
|
|
35
|
+
recovery_action: persistedRecovery.recovery_action || 'Inspect state.json and resolve manually before rerunning agentxchain step',
|
|
36
|
+
turn_retained: typeof persistedRecovery.turn_retained === 'boolean'
|
|
37
|
+
? persistedRecovery.turn_retained
|
|
38
|
+
: turnRetained,
|
|
39
|
+
detail: persistedRecovery.detail ?? state.blocked_on ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof state.blocked_on !== 'string' || !state.blocked_on.trim()) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (state.blocked_on.startsWith('human:')) {
|
|
48
|
+
return {
|
|
49
|
+
typed_reason: 'needs_human',
|
|
50
|
+
owner: 'human',
|
|
51
|
+
recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
|
|
52
|
+
turn_retained: turnRetained,
|
|
53
|
+
detail: state.blocked_on.slice('human:'.length) || null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (state.blocked_on.startsWith('human_approval:')) {
|
|
58
|
+
return {
|
|
59
|
+
typed_reason: 'pending_phase_transition',
|
|
60
|
+
owner: 'human',
|
|
61
|
+
recovery_action: 'agentxchain approve-transition',
|
|
62
|
+
turn_retained: false,
|
|
63
|
+
detail: state.blocked_on.slice('human_approval:'.length) || null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (state.blocked_on.startsWith('escalation:')) {
|
|
68
|
+
return {
|
|
69
|
+
typed_reason: 'retries_exhausted',
|
|
70
|
+
owner: 'human',
|
|
71
|
+
recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
|
|
72
|
+
turn_retained: turnRetained,
|
|
73
|
+
detail: state.blocked_on,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (state.blocked_on.startsWith('dispatch:')) {
|
|
78
|
+
return {
|
|
79
|
+
typed_reason: 'dispatch_error',
|
|
80
|
+
owner: 'human',
|
|
81
|
+
recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
|
|
82
|
+
turn_retained: turnRetained,
|
|
83
|
+
detail: state.blocked_on.slice('dispatch:'.length) || state.blocked_on,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
typed_reason: 'unknown_block',
|
|
89
|
+
owner: 'human',
|
|
90
|
+
recovery_action: 'Inspect state.json and resolve manually before rerunning agentxchain step',
|
|
91
|
+
turn_retained: turnRetained,
|
|
92
|
+
detail: state.blocked_on,
|
|
93
|
+
};
|
|
94
|
+
}
|