@enderfga/claw-orchestrator 5.1.0 → 6.0.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 +26 -26
- package/dist/bin/cli.js +107 -1
- package/dist/bin/cli.js.map +1 -1
- package/dist/src/acp-server.d.ts +5 -5
- package/dist/src/acp-server.js +3 -3
- package/dist/src/acp-server.js.map +1 -1
- package/dist/src/autoloop/dispatcher.d.ts +22 -0
- package/dist/src/autoloop/dispatcher.js +71 -13
- package/dist/src/autoloop/dispatcher.js.map +1 -1
- package/dist/src/autoloop/messages.d.ts +10 -0
- package/dist/src/autoloop/messages.js.map +1 -1
- package/dist/src/autoloop/runner.js +6 -0
- package/dist/src/autoloop/runner.js.map +1 -1
- package/dist/src/constants.d.ts +0 -6
- package/dist/src/constants.js +0 -6
- package/dist/src/constants.js.map +1 -1
- package/dist/src/council.d.ts +15 -0
- package/dist/src/council.js +48 -35
- package/dist/src/council.js.map +1 -1
- package/dist/src/dashboard/index.html +191 -6
- package/dist/src/embedded-server.js +132 -9
- package/dist/src/embedded-server.js.map +1 -1
- package/dist/src/fanout.d.ts +30 -1
- package/dist/src/fanout.js +32 -3
- package/dist/src/fanout.js.map +1 -1
- package/dist/src/index.js +359 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/kernel/agent-step.d.ts +59 -0
- package/dist/src/kernel/agent-step.js +100 -0
- package/dist/src/kernel/agent-step.js.map +1 -0
- package/dist/src/kernel/conditions.d.ts +11 -0
- package/dist/src/kernel/conditions.js +24 -0
- package/dist/src/kernel/conditions.js.map +1 -0
- package/dist/src/kernel/engine.d.ts +319 -0
- package/dist/src/kernel/engine.js +1047 -0
- package/dist/src/kernel/engine.js.map +1 -0
- package/dist/src/kernel/exec.d.ts +43 -0
- package/dist/src/kernel/exec.js +112 -0
- package/dist/src/kernel/exec.js.map +1 -0
- package/dist/src/kernel/file-lock.d.ts +50 -0
- package/dist/src/kernel/file-lock.js +135 -0
- package/dist/src/kernel/file-lock.js.map +1 -0
- package/dist/src/kernel/nodes/agent.d.ts +4 -0
- package/dist/src/kernel/nodes/agent.js +35 -0
- package/dist/src/kernel/nodes/agent.js.map +1 -0
- package/dist/src/kernel/nodes/autoloop.d.ts +78 -0
- package/dist/src/kernel/nodes/autoloop.js +75 -0
- package/dist/src/kernel/nodes/autoloop.js.map +1 -0
- package/dist/src/kernel/nodes/council.d.ts +12 -0
- package/dist/src/kernel/nodes/council.js +88 -0
- package/dist/src/kernel/nodes/council.js.map +1 -0
- package/dist/src/kernel/nodes/fanout.d.ts +11 -0
- package/dist/src/kernel/nodes/fanout.js +63 -0
- package/dist/src/kernel/nodes/fanout.js.map +1 -0
- package/dist/src/kernel/nodes/human-gate.d.ts +4 -0
- package/dist/src/kernel/nodes/human-gate.js +7 -0
- package/dist/src/kernel/nodes/human-gate.js.map +1 -0
- package/dist/src/kernel/nodes/index.d.ts +12 -0
- package/dist/src/kernel/nodes/index.js +21 -0
- package/dist/src/kernel/nodes/index.js.map +1 -0
- package/dist/src/kernel/nodes/router.d.ts +4 -0
- package/dist/src/kernel/nodes/router.js +12 -0
- package/dist/src/kernel/nodes/router.js.map +1 -0
- package/dist/src/kernel/nodes/subflow.d.ts +13 -0
- package/dist/src/kernel/nodes/subflow.js +38 -0
- package/dist/src/kernel/nodes/subflow.js.map +1 -0
- package/dist/src/kernel/nodes/ultraapp.d.ts +60 -0
- package/dist/src/kernel/nodes/ultraapp.js +62 -0
- package/dist/src/kernel/nodes/ultraapp.js.map +1 -0
- package/dist/src/kernel/nodes/verifier.d.ts +14 -0
- package/dist/src/kernel/nodes/verifier.js +84 -0
- package/dist/src/kernel/nodes/verifier.js.map +1 -0
- package/dist/src/kernel/projections.d.ts +42 -0
- package/dist/src/kernel/projections.js +133 -0
- package/dist/src/kernel/projections.js.map +1 -0
- package/dist/src/kernel/repo.d.ts +13 -0
- package/dist/src/kernel/repo.js +64 -0
- package/dist/src/kernel/repo.js.map +1 -0
- package/dist/src/kernel/secrets.d.ts +25 -0
- package/dist/src/kernel/secrets.js +48 -0
- package/dist/src/kernel/secrets.js.map +1 -0
- package/dist/src/kernel/store.d.ts +225 -0
- package/dist/src/kernel/store.js +838 -0
- package/dist/src/kernel/store.js.map +1 -0
- package/dist/src/kernel/templates/index.d.ts +140 -0
- package/dist/src/kernel/templates/index.js +266 -0
- package/dist/src/kernel/templates/index.js.map +1 -0
- package/dist/src/kernel/types.d.ts +326 -0
- package/dist/src/kernel/types.js +19 -0
- package/dist/src/kernel/types.js.map +1 -0
- package/dist/src/run-ledger.d.ts +57 -3
- package/dist/src/run-ledger.js +45 -2
- package/dist/src/run-ledger.js.map +1 -1
- package/dist/src/session-manager.d.ts +176 -129
- package/dist/src/session-manager.js +652 -603
- package/dist/src/session-manager.js.map +1 -1
- package/dist/src/types.d.ts +33 -3
- package/dist/src/ultraapp/build.d.ts +117 -3
- package/dist/src/ultraapp/build.js +319 -3
- package/dist/src/ultraapp/build.js.map +1 -1
- package/dist/src/ultraapp/contract.d.ts +52 -0
- package/dist/src/ultraapp/contract.js +83 -0
- package/dist/src/ultraapp/contract.js.map +1 -0
- package/dist/src/ultraapp/conventions.js +9 -2
- package/dist/src/ultraapp/conventions.js.map +1 -1
- package/dist/src/ultraapp/fix-on-failure.d.ts +21 -2
- package/dist/src/ultraapp/fix-on-failure.js +46 -62
- package/dist/src/ultraapp/fix-on-failure.js.map +1 -1
- package/dist/src/ultraapp/manager.d.ts +107 -2
- package/dist/src/ultraapp/manager.js +305 -86
- package/dist/src/ultraapp/manager.js.map +1 -1
- package/dist/src/verify/baseline.d.ts +73 -0
- package/dist/src/verify/baseline.js +186 -0
- package/dist/src/verify/baseline.js.map +1 -0
- package/dist/src/verify/contract.d.ts +116 -0
- package/dist/src/verify/contract.js +142 -0
- package/dist/src/verify/contract.js.map +1 -0
- package/dist/src/verify/evidence.d.ts +61 -0
- package/dist/src/verify/evidence.js +133 -0
- package/dist/src/verify/evidence.js.map +1 -0
- package/dist/src/verify/runner.d.ts +63 -0
- package/dist/src/verify/runner.js +317 -0
- package/dist/src/verify/runner.js.map +1 -0
- package/openclaw.plugin.json +8 -0
- package/package.json +2 -2
- package/skills/SKILL.md +120 -79
- package/skills/references/acp.md +17 -17
- package/skills/references/autoloop.md +139 -65
- package/skills/references/claude-cli-tracking.md +4 -4
- package/skills/references/cli.md +101 -59
- package/skills/references/council.md +109 -37
- package/skills/references/dashboard.md +34 -6
- package/skills/references/getting-started.md +13 -13
- package/skills/references/inbox.md +4 -4
- package/skills/references/mcp.md +39 -34
- package/skills/references/multi-engine.md +51 -47
- package/skills/references/observability.md +88 -28
- package/skills/references/openai-compat.md +39 -39
- package/skills/references/sessions.md +43 -25
- package/skills/references/tools.md +402 -309
- package/skills/references/ultra.md +45 -45
- package/skills/references/ultraapp.md +126 -50
- package/skills/references/verification.md +187 -0
- package/skills/references/workflow.md +362 -0
- package/dist/src/ultraapp/fix-on-failure-session.d.ts +0 -23
- package/dist/src/ultraapp/fix-on-failure-session.js +0 -51
- package/dist/src/ultraapp/fix-on-failure-session.js.map +0 -1
|
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import * as os from 'node:os';
|
|
10
10
|
import { execFile, execFileSync } from 'node:child_process';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
11
12
|
import { promisify } from 'node:util';
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
import * as http from 'node:http';
|
|
@@ -103,7 +104,17 @@ function makeDebounced(fn, ms) {
|
|
|
103
104
|
}
|
|
104
105
|
import { createConsoleLogger } from './logger.js';
|
|
105
106
|
import { CircuitBreaker } from './circuit-breaker.js';
|
|
106
|
-
import {
|
|
107
|
+
import { detectRepoLang } from './kernel/repo.js';
|
|
108
|
+
import { RunKernel, runDir as kernelRunDir } from './kernel/engine.js';
|
|
109
|
+
import { registerDefaultExecutors } from './kernel/nodes/index.js';
|
|
110
|
+
import { autoloopStateFromRecord, makeAutoloopExecutor } from './kernel/nodes/autoloop.js';
|
|
111
|
+
import { loadRun, readNodeOutput } from './kernel/store.js';
|
|
112
|
+
import { LEGACY_NODE, joinFindings, toCouncilSession, toFanoutSession, toUltraplanResult, toUltrareviewResult, } from './kernel/projections.js';
|
|
113
|
+
import { legacyCouncilWorkflow, legacyFanoutWorkflow, legacyUltraplanWorkflow, splitAgentSecrets, } from './kernel/templates/index.js';
|
|
114
|
+
import { normalizeContract } from './verify/contract.js';
|
|
115
|
+
import { runContract } from './verify/runner.js';
|
|
116
|
+
import { evidenceDir, listEvidence, readEvidence, writeEvidence } from './verify/evidence.js';
|
|
117
|
+
import { annotateVerdicts, appendRunRow, readRunLedger, summarizeRuns, } from './run-ledger.js';
|
|
107
118
|
import { checkBudget, isBudgetExceeded } from './budget.js';
|
|
108
119
|
import { InboxManager } from './inbox-manager.js';
|
|
109
120
|
import { sanitizeCwd, validateName } from './validation.js';
|
|
@@ -120,7 +131,6 @@ import { ENGINE_TYPES, overrideModelPricing, } from './types.js';
|
|
|
120
131
|
import { resolveAlias, isClaudeModel } from './models.js';
|
|
121
132
|
import { isAgyConversationId } from './agy-conversation.js';
|
|
122
133
|
import { Council } from './council.js';
|
|
123
|
-
import { Fanout } from './fanout.js';
|
|
124
134
|
import { AutoloopRunner } from './autoloop/runner.js';
|
|
125
135
|
import { ClaudeAgentDispatcher } from './autoloop/dispatcher.js';
|
|
126
136
|
import { DEFAULT_PUSH_POLICY } from './autoloop/types.js';
|
|
@@ -128,16 +138,7 @@ import { Msg as AutoloopMsg } from './autoloop/messages.js';
|
|
|
128
138
|
import { appendPushLog, notifyUserFallbackChain } from './autoloop/notify.js';
|
|
129
139
|
import { UltraappManager } from './ultraapp/manager.js';
|
|
130
140
|
import { UltraappStore, defaultStoreRoot } from './ultraapp/store.js';
|
|
131
|
-
import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEOUT_MS, GREP_HISTORY_FETCH,
|
|
132
|
-
// ─── Disk enumeration (cross-process visibility) ────────────────────────────
|
|
133
|
-
//
|
|
134
|
-
// When the dashboard's standalone clawo-serve and the OpenClaw plugin run as
|
|
135
|
-
// separate processes, each has its own in-memory map of active runs. To make
|
|
136
|
-
// past runs visible across processes we read what's persisted on disk:
|
|
137
|
-
// - Council: transcripts at ~/.openclaw/council-logs/council-*.md
|
|
138
|
-
// - Autoloop: registry at ~/.claw-orchestrator/autoloop-registry.jsonl
|
|
139
|
-
const DEFAULT_COUNCIL_LOG_DIR = path.join(os.homedir(), '.openclaw', 'council-logs');
|
|
140
|
-
const DEFAULT_AUTOLOOP_REGISTRY = path.join(os.homedir(), '.claw-orchestrator', 'autoloop-registry.jsonl');
|
|
141
|
+
import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEOUT_MS, GREP_HISTORY_FETCH, ULTRAPLAN_TIMEOUT_MS, STOP_SIGKILL_DELAY_MS, SESSION_EVENT, DEFAULT_HISTORY_LIMIT, } from './constants.js';
|
|
141
142
|
function isStringRecord(value) {
|
|
142
143
|
return (typeof value === 'object' &&
|
|
143
144
|
value !== null &&
|
|
@@ -198,128 +199,6 @@ function validateAutoloopRole(role, engine, customEngine) {
|
|
|
198
199
|
}
|
|
199
200
|
return resolved;
|
|
200
201
|
}
|
|
201
|
-
/** Append-only registry write. Safe under concurrent writers — append is atomic for short lines. */
|
|
202
|
-
export function appendAutoloopRegistry(file, entry) {
|
|
203
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
204
|
-
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
205
|
-
}
|
|
206
|
-
/**
|
|
207
|
-
* Write the current row for a run, dropping any older rows for the same id.
|
|
208
|
-
*
|
|
209
|
-
* The registry is append-only and `listAutoloopsFromRegistry` dedups on read
|
|
210
|
-
* (newest wins), so correctness never depended on cleanup — but a run now emits
|
|
211
|
-
* a row at start, another on every successful `spawn_subagents`, and another on
|
|
212
|
-
* every resume, none of which were ever removed. The file grew monotonically and
|
|
213
|
-
* every list / resume parses all of it. Callers use this AFTER the operation
|
|
214
|
-
* succeeds, so a failed start still leaves the previous row intact.
|
|
215
|
-
*/
|
|
216
|
-
export function upsertAutoloopRegistry(file, entry) {
|
|
217
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
218
|
-
removeAutoloopFromRegistry(file, entry.run_id);
|
|
219
|
-
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Read the registry, dedup by run_id (newest entry wins), drop entries whose
|
|
223
|
-
* ledger_dir no longer exists on disk (cleanup of moved/deleted workspaces).
|
|
224
|
-
* Returns entries newest-first.
|
|
225
|
-
*/
|
|
226
|
-
export function listAutoloopsFromRegistry(file = DEFAULT_AUTOLOOP_REGISTRY) {
|
|
227
|
-
if (!fs.existsSync(file))
|
|
228
|
-
return [];
|
|
229
|
-
const lines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
|
|
230
|
-
const seen = new Set();
|
|
231
|
-
const out = [];
|
|
232
|
-
// Walk in reverse so the latest entry for a given run_id wins.
|
|
233
|
-
for (const line of [...lines].reverse()) {
|
|
234
|
-
try {
|
|
235
|
-
const e = JSON.parse(line);
|
|
236
|
-
if (seen.has(e.run_id))
|
|
237
|
-
continue;
|
|
238
|
-
seen.add(e.run_id);
|
|
239
|
-
if (!fs.existsSync(e.ledger_dir))
|
|
240
|
-
continue; // stale entry, ledger gone
|
|
241
|
-
out.push(e);
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
// malformed line; skip
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return out; // already newest-first because we reversed
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Rewrite the registry with every line for the given run_id filtered out.
|
|
251
|
-
* Used by autoloopDelete to scrub a run from cross-process visibility.
|
|
252
|
-
* No-op if the file does not exist. Returns the number of lines removed.
|
|
253
|
-
*/
|
|
254
|
-
export function removeAutoloopFromRegistry(file, runId) {
|
|
255
|
-
if (!fs.existsSync(file))
|
|
256
|
-
return 0;
|
|
257
|
-
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
258
|
-
let removed = 0;
|
|
259
|
-
const kept = [];
|
|
260
|
-
for (const line of lines) {
|
|
261
|
-
if (!line) {
|
|
262
|
-
kept.push(line);
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
try {
|
|
266
|
-
const e = JSON.parse(line);
|
|
267
|
-
if (e.run_id === runId) {
|
|
268
|
-
removed += 1;
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
catch {
|
|
273
|
-
// malformed line — keep it, we only filter recognizable entries
|
|
274
|
-
}
|
|
275
|
-
kept.push(line);
|
|
276
|
-
}
|
|
277
|
-
if (removed === 0)
|
|
278
|
-
return 0;
|
|
279
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
280
|
-
fs.writeFileSync(tmp, kept.join('\n'));
|
|
281
|
-
fs.renameSync(tmp, file);
|
|
282
|
-
return removed;
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Enumerate council sessions from on-disk transcripts. Called by
|
|
286
|
-
* SessionManager.councilList() to surface runs that the current process didn't
|
|
287
|
-
* spawn itself (e.g. runs started in another process whose transcripts have
|
|
288
|
-
* already been flushed to ~/.openclaw/council-logs/).
|
|
289
|
-
*
|
|
290
|
-
* Format parsed (matches src/council.ts saveTranscript):
|
|
291
|
-
* - **ID**: <session.id>
|
|
292
|
-
* - **Time**: <iso>
|
|
293
|
-
* - **Task**: <text>
|
|
294
|
-
* - **Status**: <consensus|max_rounds|...>
|
|
295
|
-
*
|
|
296
|
-
* Legacy transcripts written before the ID field was added fall back to a
|
|
297
|
-
* filename-derived id (basename without .md). That's stable across reruns
|
|
298
|
-
* even if uncomfortable as a display id.
|
|
299
|
-
*/
|
|
300
|
-
export function listCouncilsFromDisk(logDir = DEFAULT_COUNCIL_LOG_DIR) {
|
|
301
|
-
if (!fs.existsSync(logDir))
|
|
302
|
-
return [];
|
|
303
|
-
const out = [];
|
|
304
|
-
for (const entry of fs.readdirSync(logDir)) {
|
|
305
|
-
if (!entry.startsWith('council-') || !entry.endsWith('.md'))
|
|
306
|
-
continue;
|
|
307
|
-
let head;
|
|
308
|
-
try {
|
|
309
|
-
head = fs.readFileSync(path.join(logDir, entry), 'utf-8').slice(0, 2000);
|
|
310
|
-
}
|
|
311
|
-
catch {
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
const id = /^-\s+\*\*ID\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || entry.replace(/\.md$/, '');
|
|
315
|
-
const task = /^-\s+\*\*Task\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || '(no task recorded)';
|
|
316
|
-
const startTime = /^-\s+\*\*Time\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || '';
|
|
317
|
-
const status = /^-\s+\*\*Status\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || 'unknown';
|
|
318
|
-
out.push({ id, task, status, startTime });
|
|
319
|
-
}
|
|
320
|
-
return out;
|
|
321
|
-
}
|
|
322
|
-
// ─── SessionManager ──────────────────────────────────────────────────────────
|
|
323
202
|
export class SessionManager {
|
|
324
203
|
sessions = new Map();
|
|
325
204
|
_pendingSessions = new Map();
|
|
@@ -332,6 +211,8 @@ export class SessionManager {
|
|
|
332
211
|
_activePids = new Map();
|
|
333
212
|
_circuitBreaker = new CircuitBreaker();
|
|
334
213
|
_inbox = new InboxManager();
|
|
214
|
+
/** cwd → detected language, so the manifest probe runs once per directory. */
|
|
215
|
+
_repoLangCache = new Map();
|
|
335
216
|
logger;
|
|
336
217
|
_ultraappManager = null;
|
|
337
218
|
_ultraappRouter = null;
|
|
@@ -371,6 +252,10 @@ export class SessionManager {
|
|
|
371
252
|
sessionManager: this,
|
|
372
253
|
router: this._ultraappRouter ?? undefined,
|
|
373
254
|
runtimeMode: this._ultraappRuntimeMode,
|
|
255
|
+
// The same kernel every other mode runs on, so an ultraapp build is a
|
|
256
|
+
// run like any other: listed by `workflow_list`, visible in the Runs
|
|
257
|
+
// tab, owned by one process, and resumable at a node boundary.
|
|
258
|
+
kernel: this.kernel,
|
|
374
259
|
});
|
|
375
260
|
}
|
|
376
261
|
return this._ultraappManager;
|
|
@@ -626,7 +511,10 @@ export class SessionManager {
|
|
|
626
511
|
throw err;
|
|
627
512
|
}
|
|
628
513
|
finally {
|
|
629
|
-
this._recordRunTurn(name, managed, ledgerBefore, startedAt, turnError, options.parentRunId
|
|
514
|
+
this._recordRunTurn(name, managed, ledgerBefore, startedAt, turnError, options.parentRunId, {
|
|
515
|
+
nodeKind: options.nodeKind,
|
|
516
|
+
taskKind: options.taskKind,
|
|
517
|
+
});
|
|
630
518
|
}
|
|
631
519
|
}
|
|
632
520
|
finally {
|
|
@@ -686,7 +574,7 @@ export class SessionManager {
|
|
|
686
574
|
* Rows carry per-turn deltas rather than session totals so that summing a
|
|
687
575
|
* query gives the spend for that window without double-counting.
|
|
688
576
|
*/
|
|
689
|
-
_recordRunTurn(name, managed, before, startedAt, error, parent) {
|
|
577
|
+
_recordRunTurn(name, managed, before, startedAt, error, parent, dims = {}) {
|
|
690
578
|
const after = this._statsSnapshot(managed);
|
|
691
579
|
const delta = (a, b) => Math.max(0, a - b);
|
|
692
580
|
const row = {
|
|
@@ -721,11 +609,34 @@ export class SessionManager {
|
|
|
721
609
|
row.error = error.slice(0, 500);
|
|
722
610
|
if (parent)
|
|
723
611
|
row.parent = parent;
|
|
612
|
+
if (dims.nodeKind)
|
|
613
|
+
row.nodeKind = dims.nodeKind;
|
|
614
|
+
if (dims.taskKind)
|
|
615
|
+
row.taskKind = dims.taskKind;
|
|
616
|
+
// Detected from a manifest, never guessed. `verified` is deliberately absent
|
|
617
|
+
// here: the verdict does not exist yet at turn time, and is joined in at read
|
|
618
|
+
// time by annotateVerdicts().
|
|
619
|
+
const repoLang = this._repoLang(managed.cwd);
|
|
620
|
+
if (repoLang)
|
|
621
|
+
row.repoLang = repoLang;
|
|
724
622
|
appendRunRow(row, this.logger);
|
|
725
623
|
if (isBudgetExceeded(after.costUsd, managed.config.maxBudgetUsd)) {
|
|
726
624
|
managed.budgetExhausted = true;
|
|
727
625
|
}
|
|
728
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* Repo language for the ledger row, memoised per cwd — the detector stats a
|
|
629
|
+
* handful of manifest paths and a turn-rate filesystem probe is wasteful when
|
|
630
|
+
* a session's cwd never changes.
|
|
631
|
+
*/
|
|
632
|
+
_repoLang(cwd) {
|
|
633
|
+
if (!cwd)
|
|
634
|
+
return undefined;
|
|
635
|
+
if (!this._repoLangCache.has(cwd)) {
|
|
636
|
+
this._repoLangCache.set(cwd, detectRepoLang(cwd));
|
|
637
|
+
}
|
|
638
|
+
return this._repoLangCache.get(cwd);
|
|
639
|
+
}
|
|
729
640
|
_reportedModel(managed) {
|
|
730
641
|
try {
|
|
731
642
|
return managed.session.getCost()?.model || undefined;
|
|
@@ -747,8 +658,159 @@ export class SessionManager {
|
|
|
747
658
|
* process restart and covers sessions this manager never owned.
|
|
748
659
|
*/
|
|
749
660
|
getRunLedger(query = {}) {
|
|
750
|
-
|
|
751
|
-
|
|
661
|
+
// Join each row to the verdict of the run it belonged to. The turns that did
|
|
662
|
+
// the work all finish before the verifier that judged it, so the verdict
|
|
663
|
+
// cannot be written at turn time — see `annotateVerdicts`.
|
|
664
|
+
//
|
|
665
|
+
// `verified` is deliberately withheld from the read: applying it there would
|
|
666
|
+
// filter on a field no row carries yet and return nothing. It is applied
|
|
667
|
+
// after the join instead.
|
|
668
|
+
const { verified, ...readQuery } = query;
|
|
669
|
+
const rows = annotateVerdicts(readRunLedger(readQuery, this.logger), (parent) => {
|
|
670
|
+
const record = loadRun(parent);
|
|
671
|
+
if (!record || record.outcome === 'unverified')
|
|
672
|
+
return undefined;
|
|
673
|
+
return {
|
|
674
|
+
verified: record.outcome === 'verified',
|
|
675
|
+
evidenceId: record.evidenceId,
|
|
676
|
+
contractId: record.spec?.contract?.id,
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
const filtered = verified === undefined ? rows : rows.filter((r) => r.verified === verified);
|
|
680
|
+
return { rows: filtered, summary: summarizeRuns(filtered) };
|
|
681
|
+
}
|
|
682
|
+
// ─── Workflow kernel ──────────────────────────────────────────────────────
|
|
683
|
+
/**
|
|
684
|
+
* Lazily built, like every other subsystem here — constructing it at plugin
|
|
685
|
+
* load would create run directories for a process that may never run anything.
|
|
686
|
+
*/
|
|
687
|
+
get kernel() {
|
|
688
|
+
if (!this._kernel) {
|
|
689
|
+
const kernel = registerDefaultExecutors(new RunKernel({ manager: this, logger: this.logger }), (name) => this._resolveTemplate(name));
|
|
690
|
+
// The autoloop engine needs sessions, prompt files and push channels, so
|
|
691
|
+
// its executor is registered here with a builder closed over `this`
|
|
692
|
+
// rather than living in the kernel.
|
|
693
|
+
kernel.setExecutor('autoloop', makeAutoloopExecutor({
|
|
694
|
+
boot: (config, secrets) => this._bootAutoloop({
|
|
695
|
+
...config,
|
|
696
|
+
// Custom-engine configs never reach the spec, so they come from
|
|
697
|
+
// the run's in-memory secret bag — supplied at start, and
|
|
698
|
+
// re-supplied by the caller on a resume.
|
|
699
|
+
...secrets,
|
|
700
|
+
}),
|
|
701
|
+
ready: (key, value) => {
|
|
702
|
+
const deferred = this._autoloopReady.get(key);
|
|
703
|
+
if (!deferred)
|
|
704
|
+
return;
|
|
705
|
+
if (value instanceof Error)
|
|
706
|
+
deferred.reject(value);
|
|
707
|
+
else
|
|
708
|
+
deferred.resolve(value);
|
|
709
|
+
},
|
|
710
|
+
waitForExit: (handle, signal) => this._awaitAutoloopExit(handle, signal),
|
|
711
|
+
registerPublisher: (runId, publish) => this._autoloopPublishers.set(runId, publish),
|
|
712
|
+
unregisterPublisher: (runId) => this._autoloopPublishers.delete(runId),
|
|
713
|
+
extra: (runId) => {
|
|
714
|
+
const roleSelection = this._autoloopSelection.get(runId);
|
|
715
|
+
return roleSelection ? { roleSelection } : {};
|
|
716
|
+
},
|
|
717
|
+
}));
|
|
718
|
+
this._kernel = kernel;
|
|
719
|
+
}
|
|
720
|
+
return this._kernel;
|
|
721
|
+
}
|
|
722
|
+
/** Named built-ins available to `subflow` nodes and to `workflow_start`. */
|
|
723
|
+
_resolveTemplate(name) {
|
|
724
|
+
// Built-ins need caller arguments, so a bare name only resolves to a
|
|
725
|
+
// previously started run's spec — a subflow referencing a template by name
|
|
726
|
+
// without arguments has nothing to run.
|
|
727
|
+
const record = loadRun(name);
|
|
728
|
+
return record?.spec;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Subscribe to kernel events (for the SSE endpoint). Returns an unsubscribe
|
|
732
|
+
* function — SessionManager is not an EventEmitter, and making it one just for
|
|
733
|
+
* this would widen its surface for one consumer.
|
|
734
|
+
*/
|
|
735
|
+
onWorkflowEvent(listener) {
|
|
736
|
+
const k = this.kernel;
|
|
737
|
+
k.on('kernel-event', listener);
|
|
738
|
+
return () => {
|
|
739
|
+
k.off('kernel-event', listener);
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
async workflowStart(spec, opts = {}) {
|
|
743
|
+
return this.kernel.start(spec, opts);
|
|
744
|
+
}
|
|
745
|
+
workflowStatus(runId) {
|
|
746
|
+
const record = this.kernel.get(runId);
|
|
747
|
+
if (!record)
|
|
748
|
+
throw new Error(`Workflow run '${runId}' not found`);
|
|
749
|
+
return record;
|
|
750
|
+
}
|
|
751
|
+
workflowList(query = {}) {
|
|
752
|
+
return this.kernel.list(query);
|
|
753
|
+
}
|
|
754
|
+
workflowCancel(runId) {
|
|
755
|
+
return { cancelled: this.kernel.cancel(runId) };
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Re-attach to a run.
|
|
759
|
+
*
|
|
760
|
+
* `secrets` re-supplies the material the spec deliberately does not carry —
|
|
761
|
+
* per-agent custom-engine configs, keyed as `{ agentCustomEngines: { <name>: cfg } }`.
|
|
762
|
+
* A run that used one cannot be resumed in a fresh process without them,
|
|
763
|
+
* because they were never written down.
|
|
764
|
+
*/
|
|
765
|
+
async workflowResume(runId, opts = {}) {
|
|
766
|
+
return this.kernel.resume(runId, { secrets: opts.secrets });
|
|
767
|
+
}
|
|
768
|
+
workflowSteer(runId, text) {
|
|
769
|
+
return { steered: this.kernel.steer(runId, text) };
|
|
770
|
+
}
|
|
771
|
+
workflowApprove(runId, approved) {
|
|
772
|
+
return { answered: this.kernel.approve(runId, approved) };
|
|
773
|
+
}
|
|
774
|
+
workflowDelete(runId) {
|
|
775
|
+
this.kernel.delete(runId);
|
|
776
|
+
}
|
|
777
|
+
workflowEvidence(runId, evidenceId) {
|
|
778
|
+
const dir = kernelRunDir(runId);
|
|
779
|
+
const id = evidenceId ?? this.kernel.get(runId)?.evidenceId ?? listEvidence(dir).at(-1);
|
|
780
|
+
return id ? readEvidence(dir, id) : undefined;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Run an acceptance contract against a directory, outside any workflow.
|
|
784
|
+
*
|
|
785
|
+
* This is the escape hatch for work that did not come through the kernel — a
|
|
786
|
+
* plain `session_send` that edited a repo, or a run from an older version. The
|
|
787
|
+
* contract comes from the caller and is normalized before anything executes.
|
|
788
|
+
*/
|
|
789
|
+
async verifyRun(args) {
|
|
790
|
+
const contract = normalizeContract(args.contract);
|
|
791
|
+
if (!contract)
|
|
792
|
+
throw new Error('verifyRun requires a contract with at least one recognised check');
|
|
793
|
+
const runId = args.label || `verify-${Date.now().toString(36)}`;
|
|
794
|
+
const dir = kernelRunDir(runId);
|
|
795
|
+
const evidenceId = 'verify-01';
|
|
796
|
+
const { results, rounds } = await runContract(contract, {
|
|
797
|
+
cwd: args.cwd,
|
|
798
|
+
artifactDir: evidenceDir(dir, evidenceId),
|
|
799
|
+
baseSha: args.baseSha,
|
|
800
|
+
logger: this.logger,
|
|
801
|
+
});
|
|
802
|
+
return writeEvidence({
|
|
803
|
+
runDir: dir,
|
|
804
|
+
runId,
|
|
805
|
+
node: 'run',
|
|
806
|
+
evidenceId,
|
|
807
|
+
cwd: args.cwd,
|
|
808
|
+
baseSha: args.baseSha,
|
|
809
|
+
contractId: contract.id,
|
|
810
|
+
results,
|
|
811
|
+
rounds,
|
|
812
|
+
logger: this.logger,
|
|
813
|
+
});
|
|
752
814
|
}
|
|
753
815
|
async stopSession(name, opts = {}) {
|
|
754
816
|
const managed = this._getSession(name);
|
|
@@ -1128,32 +1190,14 @@ export class SessionManager {
|
|
|
1128
1190
|
clearInterval(this.cleanupTimer);
|
|
1129
1191
|
this.cleanupTimer = null;
|
|
1130
1192
|
}
|
|
1131
|
-
//
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
this
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
clearTimeout(timer);
|
|
1140
|
-
this.councilCleanupTimers.clear();
|
|
1141
|
-
this.councils.clear();
|
|
1142
|
-
for (const [, timer] of this.fanoutCleanupTimers)
|
|
1143
|
-
clearTimeout(timer);
|
|
1144
|
-
this.fanoutCleanupTimers.clear();
|
|
1145
|
-
this.fanouts.clear();
|
|
1146
|
-
// Stop autoloops (graceful: dispatch a terminate envelope so each run
|
|
1147
|
-
// shuts down its three persistent agents and cleans up the ledger lock).
|
|
1148
|
-
for (const [, ctx] of this.autoloops) {
|
|
1149
|
-
try {
|
|
1150
|
-
await ctx.runner.send(AutoloopMsg.terminate(ctx.runner.state.iter, { reason: 'manager-shutdown' }));
|
|
1151
|
-
}
|
|
1152
|
-
catch {
|
|
1153
|
-
// Best-effort.
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
this.autoloops.clear();
|
|
1193
|
+
// Council, fan-out, ultraplan and ultrareview no longer have timers or maps
|
|
1194
|
+
// to tear down here: the kernel owns their lifecycle, and `shutdown` on it
|
|
1195
|
+
// cancels every live run. Four separate 30-minute TTL closures used to sit
|
|
1196
|
+
// in this method, each capturing `this`.
|
|
1197
|
+
// Autoloops included: cancelling their run stops the loop, which shuts down
|
|
1198
|
+
// its three persistent agents. One teardown path for every mode.
|
|
1199
|
+
if (this._kernel)
|
|
1200
|
+
await this._kernel.shutdown();
|
|
1157
1201
|
// Stop all sessions
|
|
1158
1202
|
for (const [name, managed] of this.sessions) {
|
|
1159
1203
|
try {
|
|
@@ -1905,178 +1949,151 @@ export class SessionManager {
|
|
|
1905
1949
|
}
|
|
1906
1950
|
}
|
|
1907
1951
|
// ─── Council ──────────────────────────────────────────────────────────
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
})
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
// Abort if still running to prevent orphaned background tasks
|
|
1935
|
-
const council = this.councils.get(id);
|
|
1936
|
-
if (council) {
|
|
1937
|
-
const session = council.getSession();
|
|
1938
|
-
if (session?.status === 'running') {
|
|
1939
|
-
this.logger.info(`Council ${id} still running at TTL expiry — aborting`);
|
|
1940
|
-
council.abort();
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
this.councils.delete(id);
|
|
1944
|
-
this.councilCleanupTimers.delete(id);
|
|
1945
|
-
}, RESULT_TTL_MS);
|
|
1946
|
-
// Don't let a pending 30-min cleanup timer keep the process alive or block shutdown.
|
|
1947
|
-
timer.unref();
|
|
1948
|
-
this.councilCleanupTimers.set(id, timer);
|
|
1949
|
-
}
|
|
1950
|
-
/** Clear and forget a cleanup timer (used on abort/shutdown so it can't fire late). */
|
|
1951
|
-
_clearCleanupTimer(map, id) {
|
|
1952
|
-
const t = map.get(id);
|
|
1953
|
-
if (t) {
|
|
1954
|
-
clearTimeout(t);
|
|
1955
|
-
map.delete(id);
|
|
1956
|
-
}
|
|
1952
|
+
//
|
|
1953
|
+
// The council's lifecycle belongs to the run kernel now. What used to live
|
|
1954
|
+
// here — a `Map` of live `Council` objects, a 30-minute TTL timer per entry,
|
|
1955
|
+
// and a `councilList` that regex-scraped markdown transcripts to see runs from
|
|
1956
|
+
// other processes — is gone. A council is a one-node workflow; its state is
|
|
1957
|
+
// the run record, which is durable, cross-process, and does not evaporate.
|
|
1958
|
+
//
|
|
1959
|
+
// What still needs a live object is in-flight control: `inject` and `abort`
|
|
1960
|
+
// have to reach the `Council` instance that is running right now. The kernel
|
|
1961
|
+
// publishes it for the duration of the node, and says so honestly — after a
|
|
1962
|
+
// restart the run is readable and resumable, but there is no turn to inject
|
|
1963
|
+
// into.
|
|
1964
|
+
async councilStart(task, config) {
|
|
1965
|
+
const runId = `council-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
1966
|
+
const { agents, secrets } = splitAgentSecrets(config.agents);
|
|
1967
|
+
const record = await this.kernel.start(legacyCouncilWorkflow({
|
|
1968
|
+
task,
|
|
1969
|
+
cwd: config.projectDir,
|
|
1970
|
+
agents,
|
|
1971
|
+
maxRounds: config.maxRounds,
|
|
1972
|
+
timeoutMs: config.agentTimeoutMs,
|
|
1973
|
+
maxTurnsPerAgent: config.maxTurnsPerAgent,
|
|
1974
|
+
maxBudgetUsd: config.maxBudgetUsd,
|
|
1975
|
+
defaultPermissionMode: config.defaultPermissionMode,
|
|
1976
|
+
}), { runId, cwd: config.projectDir, secrets: { agentCustomEngines: secrets } });
|
|
1977
|
+
return toCouncilSession(record);
|
|
1957
1978
|
}
|
|
1958
1979
|
councilStatus(id) {
|
|
1959
|
-
const
|
|
1960
|
-
|
|
1980
|
+
const record = loadRun(id);
|
|
1981
|
+
if (!record || record.workflow !== 'council')
|
|
1982
|
+
return undefined;
|
|
1983
|
+
return toCouncilSession(record);
|
|
1961
1984
|
}
|
|
1962
1985
|
/**
|
|
1963
|
-
*
|
|
1986
|
+
* Every council this machine has run, newest first.
|
|
1964
1987
|
*
|
|
1965
|
-
*
|
|
1966
|
-
*
|
|
1967
|
-
*
|
|
1968
|
-
* (e.g. plugin-managed runs visible to a standalone clawo-serve dashboard).
|
|
1969
|
-
* Dedup by id; in-memory wins. Sorted by startTime descending so the newest
|
|
1970
|
-
* appears at the top of the sidebar.
|
|
1988
|
+
* Cross-process visibility used to come from scraping `~/.openclaw/council-logs/*.md`
|
|
1989
|
+
* with a regex and fabricating a stub session with no responses and an empty
|
|
1990
|
+
* config. Runs are stored records now, so the dashboard sees the real thing.
|
|
1971
1991
|
*/
|
|
1972
1992
|
councilList() {
|
|
1973
|
-
|
|
1974
|
-
.
|
|
1975
|
-
.
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
.filter((r) => !inMemIds.has(r.id))
|
|
1979
|
-
.map((r) => ({
|
|
1980
|
-
id: r.id,
|
|
1981
|
-
task: r.task,
|
|
1982
|
-
status: r.status,
|
|
1983
|
-
startTime: r.startTime,
|
|
1984
|
-
responses: [],
|
|
1985
|
-
config: { agents: [], maxRounds: 0, projectDir: '' },
|
|
1986
|
-
}));
|
|
1987
|
-
return [...inMemory, ...fromDisk].sort((a, b) => (b.startTime || '').localeCompare(a.startTime || ''));
|
|
1993
|
+
return this.kernel
|
|
1994
|
+
.list({ workflow: 'council' })
|
|
1995
|
+
.map((r) => loadRun(r.runId))
|
|
1996
|
+
.filter((r) => Boolean(r))
|
|
1997
|
+
.map(toCouncilSession);
|
|
1988
1998
|
}
|
|
1989
1999
|
/** Used by embedded-server to subscribe to a council's event stream. */
|
|
1990
2000
|
getCouncil(id) {
|
|
1991
|
-
return this.
|
|
2001
|
+
return this.kernel.handle(id, LEGACY_NODE);
|
|
2002
|
+
}
|
|
2003
|
+
/** The live council for a run, or a clear error about why there isn't one. */
|
|
2004
|
+
_liveCouncil(id) {
|
|
2005
|
+
const council = this.kernel.handle(id, LEGACY_NODE);
|
|
2006
|
+
if (council)
|
|
2007
|
+
return council;
|
|
2008
|
+
const record = loadRun(id);
|
|
2009
|
+
if (!record)
|
|
2010
|
+
throw new Error(`Council '${id}' not found`);
|
|
2011
|
+
throw new Error(`Council '${id}' is ${record.state} and not running in this process — its record is readable, but there is no live round to act on`);
|
|
1992
2012
|
}
|
|
1993
2013
|
councilAbort(id) {
|
|
1994
|
-
|
|
1995
|
-
|
|
2014
|
+
// Cancel the run first so the kernel stops advancing, then abort the engine
|
|
2015
|
+
// so the current round tears down its worktrees.
|
|
2016
|
+
if (!this.kernel.cancel(id) && !loadRun(id))
|
|
1996
2017
|
throw new Error(`Council '${id}' not found`);
|
|
1997
|
-
|
|
1998
|
-
this.councils.delete(id);
|
|
1999
|
-
// Drop the orphaned cleanup timer so it doesn't fire later on a deleted council.
|
|
2000
|
-
this._clearCleanupTimer(this.councilCleanupTimers, id);
|
|
2018
|
+
this.kernel.handle(id, LEGACY_NODE)?.abort();
|
|
2001
2019
|
}
|
|
2002
2020
|
councilInject(id, message) {
|
|
2003
|
-
|
|
2004
|
-
if (!council)
|
|
2005
|
-
throw new Error(`Council '${id}' not found`);
|
|
2006
|
-
council.injectMessage(message);
|
|
2021
|
+
this._liveCouncil(id).injectMessage(message);
|
|
2007
2022
|
}
|
|
2008
2023
|
async councilReview(id) {
|
|
2009
|
-
|
|
2010
|
-
if (!council)
|
|
2011
|
-
throw new Error(`Council '${id}' not found`);
|
|
2012
|
-
this._scheduleCouncilCleanup(id); // reset TTL — user is actively reviewing
|
|
2013
|
-
return council.review();
|
|
2024
|
+
return this._councilForPostProcessing(id).review();
|
|
2014
2025
|
}
|
|
2015
2026
|
async councilAccept(id) {
|
|
2016
|
-
|
|
2017
|
-
if (!council)
|
|
2018
|
-
throw new Error(`Council '${id}' not found`);
|
|
2019
|
-
const result = await council.accept();
|
|
2020
|
-
// Accepted — no longer needed, clean up after short grace period
|
|
2021
|
-
this._scheduleCouncilCleanup(id);
|
|
2022
|
-
return result;
|
|
2027
|
+
return this._councilForPostProcessing(id).accept();
|
|
2023
2028
|
}
|
|
2024
2029
|
async councilReject(id, feedback) {
|
|
2025
|
-
|
|
2026
|
-
|
|
2030
|
+
return this._councilForPostProcessing(id).reject(feedback);
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* A `Council` for review / accept / reject.
|
|
2034
|
+
*
|
|
2035
|
+
* These three act on the git state a finished council left behind — branches,
|
|
2036
|
+
* worktrees, plan.md — so they do not need the instance that produced it, only
|
|
2037
|
+
* one pointed at the same project directory. Reconstructing from the run
|
|
2038
|
+
* record is what makes them work after a restart, which the in-memory map made
|
|
2039
|
+
* impossible.
|
|
2040
|
+
*/
|
|
2041
|
+
_councilForPostProcessing(id) {
|
|
2042
|
+
const live = this.kernel.handle(id, LEGACY_NODE);
|
|
2043
|
+
if (live)
|
|
2044
|
+
return live;
|
|
2045
|
+
const record = loadRun(id);
|
|
2046
|
+
if (!record || record.workflow !== 'council')
|
|
2027
2047
|
throw new Error(`Council '${id}' not found`);
|
|
2028
|
-
const
|
|
2029
|
-
|
|
2030
|
-
|
|
2048
|
+
const session = toCouncilSession(record);
|
|
2049
|
+
const council = new Council(session.config, this, this.logger);
|
|
2050
|
+
council.adoptSession(session);
|
|
2051
|
+
return council;
|
|
2031
2052
|
}
|
|
2032
2053
|
// ─── Fan-out (parallel multi-engine task, no consensus) ────────────────
|
|
2033
|
-
|
|
2034
|
-
|
|
2054
|
+
//
|
|
2055
|
+
// Also a one-node workflow. This is the mode the old design failed hardest:
|
|
2056
|
+
// a fan-out wrote nothing to disk at all, so 30 minutes after it finished
|
|
2057
|
+
// `fanoutStatus` threw "not found" and the results were simply gone.
|
|
2035
2058
|
/**
|
|
2036
2059
|
* Start a fan-out: run the task across N engine/model agents in parallel and
|
|
2037
2060
|
* collect their answers (optional synthesis). Runs in the background; poll
|
|
2038
2061
|
* with fanoutStatus. Distinct from council — no rounds, votes, or worktrees.
|
|
2039
2062
|
*/
|
|
2040
|
-
fanoutStart(config) {
|
|
2063
|
+
async fanoutStart(config) {
|
|
2041
2064
|
if (!config.agents?.length)
|
|
2042
2065
|
throw new Error('fanoutStart: at least one agent is required');
|
|
2043
2066
|
const names = config.agents.map((a) => a.name);
|
|
2044
2067
|
if (new Set(names).size !== names.length) {
|
|
2045
2068
|
throw new Error('fanoutStart: agent names must be unique (they form session names)');
|
|
2046
2069
|
}
|
|
2047
|
-
const
|
|
2048
|
-
const
|
|
2049
|
-
this.
|
|
2050
|
-
|
|
2051
|
-
.
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2070
|
+
const runId = `fanout-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
2071
|
+
const { agents, secrets } = splitAgentSecrets(config.agents);
|
|
2072
|
+
const record = await this.kernel.start(legacyFanoutWorkflow({
|
|
2073
|
+
task: config.task,
|
|
2074
|
+
cwd: config.projectDir,
|
|
2075
|
+
agents,
|
|
2076
|
+
synthesize: config.synthesize,
|
|
2077
|
+
synthesisEngine: config.synthesisEngine,
|
|
2078
|
+
synthesisModel: config.synthesisModel,
|
|
2079
|
+
synthesisPermissionMode: config.synthesisPermissionMode,
|
|
2080
|
+
maxTurnsPerAgent: config.maxTurnsPerAgent,
|
|
2081
|
+
maxBudgetUsd: config.maxBudgetUsd,
|
|
2082
|
+
timeoutMs: config.agentTimeoutMs,
|
|
2083
|
+
}), { runId, cwd: config.projectDir, secrets: { agentCustomEngines: secrets } });
|
|
2084
|
+
return toFanoutSession(record);
|
|
2055
2085
|
}
|
|
2056
2086
|
fanoutStatus(id) {
|
|
2057
|
-
const
|
|
2058
|
-
if (!
|
|
2087
|
+
const record = loadRun(id);
|
|
2088
|
+
if (!record)
|
|
2059
2089
|
throw new Error(`Fanout '${id}' not found`);
|
|
2060
|
-
return
|
|
2090
|
+
return toFanoutSession(record);
|
|
2061
2091
|
}
|
|
2062
2092
|
fanoutAbort(id) {
|
|
2063
|
-
|
|
2064
|
-
if (!fanout)
|
|
2093
|
+
if (!loadRun(id))
|
|
2065
2094
|
throw new Error(`Fanout '${id}' not found`);
|
|
2066
|
-
|
|
2067
|
-
this.
|
|
2068
|
-
}
|
|
2069
|
-
_scheduleFanoutCleanup(id) {
|
|
2070
|
-
const existing = this.fanoutCleanupTimers.get(id);
|
|
2071
|
-
if (existing)
|
|
2072
|
-
clearTimeout(existing);
|
|
2073
|
-
const timer = setTimeout(() => {
|
|
2074
|
-
this.fanouts.delete(id);
|
|
2075
|
-
this.fanoutCleanupTimers.delete(id);
|
|
2076
|
-
}, RESULT_TTL_MS);
|
|
2077
|
-
if (typeof timer.unref === 'function')
|
|
2078
|
-
timer.unref();
|
|
2079
|
-
this.fanoutCleanupTimers.set(id, timer);
|
|
2095
|
+
this.kernel.cancel(id);
|
|
2096
|
+
this.kernel.handle(id, LEGACY_NODE)?.abort();
|
|
2080
2097
|
}
|
|
2081
2098
|
// ─── Inbox (cross-session messaging) — delegated to InboxManager ────
|
|
2082
2099
|
get _sessionLookup() {
|
|
@@ -2098,79 +2115,66 @@ export class SessionManager {
|
|
|
2098
2115
|
return this._inbox.deliverInbox(name, this._sessionLookup);
|
|
2099
2116
|
}
|
|
2100
2117
|
// ─── Ultraplan ────────────────────────────────────────────────────────
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
return result;
|
|
2139
|
-
}
|
|
2140
|
-
async _runUltraplan(id, sessionName, task, model, cwd, timeout) {
|
|
2141
|
-
const result = this.ultraplans.get(id);
|
|
2142
|
-
await this.startSession({
|
|
2143
|
-
name: sessionName,
|
|
2118
|
+
//
|
|
2119
|
+
// A one-node workflow. What is gone: a `Map` of results, and an inline
|
|
2120
|
+
// 30-minute timer that doubled as the timeout — a plan still running when the
|
|
2121
|
+
// TTL fired was rewritten as `error: 'Timed out (TTL expired)'` and then
|
|
2122
|
+
// deleted, so a long plan could be destroyed by its own eviction timer. The
|
|
2123
|
+
// node's `timeoutMs` is the timeout now, and the record does not expire.
|
|
2124
|
+
_kernel = null;
|
|
2125
|
+
/**
|
|
2126
|
+
* Deferreds resolved by the `autoloop` node once its engine is up, so
|
|
2127
|
+
* `autoloopStart` can return the Planner session name the caller expects
|
|
2128
|
+
* without polling.
|
|
2129
|
+
*/
|
|
2130
|
+
/**
|
|
2131
|
+
* Deferreds resolved by the `autoloop` node once its engine is up.
|
|
2132
|
+
*
|
|
2133
|
+
* Keyed by the start's tag rather than its run id. A run id gets reused — a
|
|
2134
|
+
* start that failed frees it for a retry — so keying on the id let a dying
|
|
2135
|
+
* start settle, or clear, the retry's deferred instead of its own, and the
|
|
2136
|
+
* retry then waited forever for a signal with nowhere to land.
|
|
2137
|
+
*/
|
|
2138
|
+
_autoloopReady = new Map();
|
|
2139
|
+
/**
|
|
2140
|
+
* Run ids with a start in flight — the window between "run created" and
|
|
2141
|
+
* "engine up". Deleting inside it would drop the run while its Planner
|
|
2142
|
+
* session is still being created, orphaning a session that finishes a moment
|
|
2143
|
+
* later with nothing pointing at it.
|
|
2144
|
+
*/
|
|
2145
|
+
_autoloopStarting = new Map();
|
|
2146
|
+
/** Latest role selection per run, published into the node payload. */
|
|
2147
|
+
_autoloopSelection = new Map();
|
|
2148
|
+
/** Per-run checkpoint refreshers, registered by the autoloop node executor. */
|
|
2149
|
+
_autoloopPublishers = new Map();
|
|
2150
|
+
async ultraplanStart(task, opts) {
|
|
2151
|
+
const runId = `ultraplan-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
2152
|
+
const cwd = opts?.cwd || process.cwd();
|
|
2153
|
+
const record = await this.kernel.start(legacyUltraplanWorkflow({
|
|
2154
|
+
task,
|
|
2144
2155
|
cwd,
|
|
2145
|
-
model,
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
});
|
|
2150
|
-
const planPrompt = `# Ultraplan Task\n\n${task}\n\nExplore the project, understand the codebase, analyze feasibility, and produce a comprehensive implementation plan. Take your time (up to 30 minutes). Be thorough.`;
|
|
2151
|
-
const sendResult = await this.sendMessage(sessionName, planPrompt, { timeout });
|
|
2152
|
-
// Detect error responses: empty output or output that looks like an error message
|
|
2153
|
-
const output = sendResult.output?.trim() || '';
|
|
2154
|
-
const looksLikeError = !output ||
|
|
2155
|
-
/^(Error|not logged in|authentication|auth failed|permission denied)/i.test(output) ||
|
|
2156
|
-
(sendResult.error && sendResult.error.length > 0);
|
|
2157
|
-
if (looksLikeError) {
|
|
2158
|
-
result.status = 'error';
|
|
2159
|
-
result.error = sendResult.error || output || 'Empty response from engine';
|
|
2160
|
-
}
|
|
2161
|
-
else {
|
|
2162
|
-
result.plan = output;
|
|
2163
|
-
result.status = 'completed';
|
|
2164
|
-
}
|
|
2165
|
-
result.endTime = new Date().toISOString();
|
|
2156
|
+
model: opts?.model || 'opus',
|
|
2157
|
+
timeoutMs: opts?.timeout || ULTRAPLAN_TIMEOUT_MS,
|
|
2158
|
+
}), { runId, cwd });
|
|
2159
|
+
return toUltraplanResult(record, undefined);
|
|
2166
2160
|
}
|
|
2167
2161
|
ultraplanStatus(id) {
|
|
2168
|
-
|
|
2162
|
+
const record = loadRun(id);
|
|
2163
|
+
if (!record || record.workflow !== 'ultraplan')
|
|
2164
|
+
return undefined;
|
|
2165
|
+
// Read the plan from the node artifact, not the record's preview: a plan is
|
|
2166
|
+
// routinely longer than the inline cap, and returning a truncated one would
|
|
2167
|
+
// quietly hand back a broken deliverable.
|
|
2168
|
+
return toUltraplanResult(record, readNodeOutput(id, LEGACY_NODE));
|
|
2169
2169
|
}
|
|
2170
2170
|
// ─── Ultrareview ──────────────────────────────────────────────────────
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2171
|
+
// No map and no poller. Ultrareview used to hold its results in a `Map`, then
|
|
2172
|
+
// `setInterval` every 5 seconds asking the fan-out whether it had finished —
|
|
2173
|
+
// which meant its correctness depended on the fan-out's 30-minute eviction
|
|
2174
|
+
// timer: evict first and the poll threw, the interval was cleared, and the
|
|
2175
|
+
// review stayed `running` forever. It is one run now, and there is nothing to
|
|
2176
|
+
// poll.
|
|
2177
|
+
async ultrareviewStart(cwd, opts) {
|
|
2174
2178
|
const id = `ultrareview-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
2175
2179
|
const agentCount = Math.min(20, Math.max(1, opts?.agentCount || 5));
|
|
2176
2180
|
const result = {
|
|
@@ -2180,7 +2184,6 @@ export class SessionManager {
|
|
|
2180
2184
|
agentCount,
|
|
2181
2185
|
startTime: new Date().toISOString(),
|
|
2182
2186
|
};
|
|
2183
|
-
this.ultrareviews.set(id, result);
|
|
2184
2187
|
// Build reviewer agents
|
|
2185
2188
|
const reviewAngles = [
|
|
2186
2189
|
{
|
|
@@ -2304,92 +2307,93 @@ export class SessionManager {
|
|
|
2304
2307
|
// opt-in via `engines`, run under their engine's default sandbox.)
|
|
2305
2308
|
permissionMode: 'plan',
|
|
2306
2309
|
}));
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
//
|
|
2320
|
-
//
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
// fan-out id (an opaque run id used only by ultrareview_status).
|
|
2329
|
-
result.councilId = fanoutSession.id;
|
|
2330
|
-
// Poll the fan-out for completion (store ref for shutdown cleanup).
|
|
2331
|
-
const pollInterval = setInterval(() => {
|
|
2332
|
-
try {
|
|
2333
|
-
const status = this.fanoutStatus(fanoutSession.id);
|
|
2334
|
-
if (!status || status.status === 'running')
|
|
2335
|
-
return;
|
|
2336
|
-
clearInterval(pollInterval);
|
|
2337
|
-
this.ultrareviewPollers.delete(id);
|
|
2338
|
-
result.status = status.status === 'error' ? 'error' : 'completed';
|
|
2339
|
-
result.endTime = new Date().toISOString();
|
|
2340
|
-
// Prefer the synthesis pass; fall back to joining successful results.
|
|
2341
|
-
if (status.synthesis) {
|
|
2342
|
-
result.findings = status.synthesis;
|
|
2343
|
-
}
|
|
2344
|
-
else if (status.results.length > 0) {
|
|
2345
|
-
result.findings = status.results
|
|
2346
|
-
.filter((r) => r.ok)
|
|
2347
|
-
.map((r) => `## ${r.agent}\n\n${r.output}`)
|
|
2348
|
-
.join('\n\n---\n\n');
|
|
2349
|
-
}
|
|
2350
|
-
{
|
|
2351
|
-
const ttlDelete = setTimeout(() => this.ultrareviews.delete(id), RESULT_TTL_MS);
|
|
2352
|
-
ttlDelete.unref();
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
catch {
|
|
2356
|
-
// Fan-out may have been cleaned up; stop polling.
|
|
2357
|
-
clearInterval(pollInterval);
|
|
2358
|
-
this.ultrareviewPollers.delete(id);
|
|
2359
|
-
}
|
|
2360
|
-
}, ULTRAREVIEW_POLL_INTERVAL_MS);
|
|
2361
|
-
this.ultrareviewPollers.set(id, pollInterval);
|
|
2310
|
+
const runId = id;
|
|
2311
|
+
await this.kernel.start(legacyFanoutWorkflow({
|
|
2312
|
+
name: 'ultrareview',
|
|
2313
|
+
task: reviewInstruction,
|
|
2314
|
+
cwd,
|
|
2315
|
+
// Each reviewer's own prompt and `permissionMode: 'plan'` travel with it.
|
|
2316
|
+
// They were being dropped, so every reviewer got the shared task under
|
|
2317
|
+
// `bypassPermissions` — a read-only review that could edit the code.
|
|
2318
|
+
agents,
|
|
2319
|
+
synthesize: true,
|
|
2320
|
+
// The synthesiser reads the reviewers' text, not the code, and it shares
|
|
2321
|
+
// the project directory — so it is held to the same read-only rule. It
|
|
2322
|
+
// was not, which meant an ultrareview could still write through its
|
|
2323
|
+
// final pass.
|
|
2324
|
+
synthesisPermissionMode: 'plan',
|
|
2325
|
+
maxTurnsPerAgent: 20,
|
|
2326
|
+
timeoutMs: maxMinutes * 60 * 1000,
|
|
2327
|
+
}), { runId, cwd });
|
|
2328
|
+
// `councilId` is kept for the UltrareviewResult contract; it holds the run
|
|
2329
|
+
// id, which is also the fan-out id — they are the same run now.
|
|
2330
|
+
result.councilId = runId;
|
|
2362
2331
|
return result;
|
|
2363
2332
|
}
|
|
2364
2333
|
ultrareviewStatus(id) {
|
|
2365
|
-
|
|
2334
|
+
const record = loadRun(id);
|
|
2335
|
+
if (!record || record.workflow !== 'ultrareview')
|
|
2336
|
+
return undefined;
|
|
2337
|
+
const data = record.nodes[LEGACY_NODE]?.data;
|
|
2338
|
+
return toUltrareviewResult(record, joinFindings(data));
|
|
2366
2339
|
}
|
|
2367
2340
|
// ─── Autoloop (three-agent architecture) ───────────────────────────
|
|
2368
|
-
|
|
2369
|
-
//
|
|
2370
|
-
//
|
|
2371
|
-
//
|
|
2372
|
-
|
|
2341
|
+
// No map, no registry file, and no start/delete fences.
|
|
2342
|
+
//
|
|
2343
|
+
// What used to live here: `autoloops`, holding the live runner and dispatcher;
|
|
2344
|
+
// `_deletingAutoloops` and `_startingAutoloops`, two `Set`s that existed only
|
|
2345
|
+
// because a start and a delete could race each other over that map; and four
|
|
2346
|
+
// bespoke helpers over `~/.claw-orchestrator/autoloop-registry.jsonl` for
|
|
2347
|
+
// cross-process listing. A run has exactly one owner now, run ids collide in
|
|
2348
|
+
// the run store rather than in a map that only saw this process, and the
|
|
2349
|
+
// record is the registry.
|
|
2373
2350
|
/**
|
|
2374
|
-
*
|
|
2375
|
-
*
|
|
2376
|
-
*
|
|
2377
|
-
*
|
|
2378
|
-
*
|
|
2351
|
+
* Build and start the Planner/Coder/Reviewer engine for a run.
|
|
2352
|
+
*
|
|
2353
|
+
* This is everything `autoloopStart` used to be except the bookkeeping: the
|
|
2354
|
+
* `autoloops` map and the private JSONL registry are gone, and the kernel owns
|
|
2355
|
+
* the lifecycle. Called from the `autoloop` node executor, which holds the
|
|
2356
|
+
* returned objects for as long as the loop runs.
|
|
2379
2357
|
*/
|
|
2380
|
-
_startingAutoloops = new Set();
|
|
2381
2358
|
/**
|
|
2382
|
-
*
|
|
2383
|
-
*
|
|
2384
|
-
*
|
|
2359
|
+
* Resolve until the loop stops.
|
|
2360
|
+
*
|
|
2361
|
+
* The runner is an event emitter, not a promise: it settles when a
|
|
2362
|
+
* `terminate` envelope is drained or the phase-error circuit trips. Cancelling
|
|
2363
|
+
* the run stops it too, which is what makes `workflow_cancel` work on an
|
|
2364
|
+
* autoloop.
|
|
2385
2365
|
*/
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2366
|
+
_awaitAutoloopExit(handle, signal) {
|
|
2367
|
+
return new Promise((resolve) => {
|
|
2368
|
+
const runner = handle.runner;
|
|
2369
|
+
const done = () => runner.state.status === 'terminated' || runner.state.status === 'crashed';
|
|
2370
|
+
if (done())
|
|
2371
|
+
return resolve();
|
|
2372
|
+
const check = () => {
|
|
2373
|
+
if (done() || signal.aborted) {
|
|
2374
|
+
runner.off('state', check);
|
|
2375
|
+
clearInterval(poll);
|
|
2376
|
+
if (signal.aborted) {
|
|
2377
|
+
// Cancelling a run has to tear the loop down the way a stop does.
|
|
2378
|
+
// Without this the three persistent agents keep running and their
|
|
2379
|
+
// session names stay claimed, so the run cannot be restarted — the
|
|
2380
|
+
// failure looks like "session name already in use" a long way from
|
|
2381
|
+
// its cause.
|
|
2382
|
+
runner.stop();
|
|
2383
|
+
void handle.dispatcher.shutdown('cancelled').catch(() => undefined);
|
|
2384
|
+
}
|
|
2385
|
+
resolve();
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
runner.on('state', check);
|
|
2389
|
+
// The runner emits on state changes, but a cancel arrives out of band and
|
|
2390
|
+
// a crashed loop may emit nothing at all, so poll as the backstop.
|
|
2391
|
+
const poll = setInterval(check, 1000);
|
|
2392
|
+
if (typeof poll.unref === 'function')
|
|
2393
|
+
poll.unref();
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
async _bootAutoloop(opts) {
|
|
2393
2397
|
const plannerEngine = validateAutoloopRole('planner', opts.plannerEngine, opts.plannerCustomEngine);
|
|
2394
2398
|
const coderEngine = validateAutoloopRole('coder', opts.coderEngine, opts.coderCustomEngine);
|
|
2395
2399
|
const reviewerEngine = validateAutoloopRole('reviewer', opts.reviewerEngine, opts.reviewerCustomEngine);
|
|
@@ -2432,24 +2436,11 @@ export class SessionManager {
|
|
|
2432
2436
|
runnerRef?.markSubagentsSpawned();
|
|
2433
2437
|
},
|
|
2434
2438
|
onRoleSelectionChanged: async (selection) => {
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
started_at: runnerRef?.state.started_at ?? new Date().toISOString(),
|
|
2441
|
-
planner_session: dispatcherRef?.sessionNames.planner ?? `autoloop-${runId}-planner`,
|
|
2442
|
-
planner_engine: plannerEngine,
|
|
2443
|
-
planner_model: opts.plannerModel,
|
|
2444
|
-
coder_engine: selection.coder.engine,
|
|
2445
|
-
coder_model: selection.coder.model,
|
|
2446
|
-
reviewer_engine: selection.reviewer.engine,
|
|
2447
|
-
reviewer_model: selection.reviewer.model,
|
|
2448
|
-
});
|
|
2449
|
-
}
|
|
2450
|
-
catch (err) {
|
|
2451
|
-
this.logger.warn?.(`[autoloop/${runId}] registry update after spawn failed: ${err.message}`);
|
|
2452
|
-
}
|
|
2439
|
+
// Used to write a row into a private append-only registry file. The run
|
|
2440
|
+
// record is the registry now, so this just refreshes the published
|
|
2441
|
+
// payload the `autoloop_status` projection reads.
|
|
2442
|
+
this._autoloopSelection.set(runId, selection);
|
|
2443
|
+
this._autoloopPublishers.get(runId)?.();
|
|
2453
2444
|
},
|
|
2454
2445
|
};
|
|
2455
2446
|
const dispatcher = new ClaudeAgentDispatcher(dispatcherConfig);
|
|
@@ -2480,19 +2471,10 @@ export class SessionManager {
|
|
|
2480
2471
|
dispatcher,
|
|
2481
2472
|
});
|
|
2482
2473
|
runnerRef = runner;
|
|
2483
|
-
this.autoloops.set(opts.runId, {
|
|
2484
|
-
runner,
|
|
2485
|
-
dispatcher,
|
|
2486
|
-
workspace: opts.workspace,
|
|
2487
|
-
ledgerDir,
|
|
2488
|
-
pushPolicy,
|
|
2489
|
-
});
|
|
2490
|
-
this._startingAutoloops.add(opts.runId);
|
|
2491
2474
|
try {
|
|
2492
2475
|
await runner.start();
|
|
2493
2476
|
}
|
|
2494
2477
|
catch (err) {
|
|
2495
|
-
this.autoloops.delete(opts.runId);
|
|
2496
2478
|
try {
|
|
2497
2479
|
await dispatcher.shutdown('start-failed', { purge: true });
|
|
2498
2480
|
}
|
|
@@ -2502,44 +2484,82 @@ export class SessionManager {
|
|
|
2502
2484
|
runner.stop();
|
|
2503
2485
|
throw err;
|
|
2504
2486
|
}
|
|
2505
|
-
|
|
2506
|
-
|
|
2487
|
+
return { runner, dispatcher, ledgerDir, pushPolicy };
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Start a v2 autoloop in chat mode. Creates the Planner persistent session,
|
|
2491
|
+
* returns the run handle. Coder/Reviewer are NOT started until S3's
|
|
2492
|
+
* spawn_subagents tool is called.
|
|
2493
|
+
*
|
|
2494
|
+
* The run is a kernel run whose single `autoloop` node holds the loop for as
|
|
2495
|
+
* long as it lives. That is what replaced the `autoloops` map, the
|
|
2496
|
+
* `autoloop-registry.jsonl` file with its four bespoke read/write helpers, and
|
|
2497
|
+
* the two `Set`s that fenced start against delete: a run has one owner now,
|
|
2498
|
+
* and `runId` collisions are refused by the run store rather than by a map
|
|
2499
|
+
* lookup that only saw this process.
|
|
2500
|
+
*/
|
|
2501
|
+
async autoloopStart(opts) {
|
|
2502
|
+
// Fail before the run directory exists, so a rejected start leaves nothing.
|
|
2503
|
+
validateAutoloopRole('planner', opts.plannerEngine, opts.plannerCustomEngine);
|
|
2504
|
+
validateAutoloopRole('coder', opts.coderEngine, opts.coderCustomEngine);
|
|
2505
|
+
validateAutoloopRole('reviewer', opts.reviewerEngine, opts.reviewerCustomEngine);
|
|
2506
|
+
for (const role of ['planner', 'coder', 'reviewer']) {
|
|
2507
|
+
const sessionName = `autoloop-${opts.runId}-${role}`;
|
|
2508
|
+
if (this.sessions.has(sessionName) || this._pendingSessions.has(sessionName)) {
|
|
2509
|
+
throw new Error(`Autoloop session name '${sessionName}' is already in use`);
|
|
2510
|
+
}
|
|
2507
2511
|
}
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2512
|
+
const tag = `${opts.runId}:${randomUUID()}`;
|
|
2513
|
+
const ready = new Promise((resolve, reject) => {
|
|
2514
|
+
this._autoloopReady.set(tag, { resolve, reject });
|
|
2515
|
+
});
|
|
2516
|
+
this._autoloopStarting.set(tag, opts.runId);
|
|
2517
|
+
// Custom-engine configs hold credentials and the spec is written to disk, so
|
|
2518
|
+
// they travel in memory. Without this split, `spec.json` contained the token
|
|
2519
|
+
// from `CustomEngineConfig.env` in plain text.
|
|
2520
|
+
const { plannerCustomEngine, coderCustomEngine, reviewerCustomEngine, ...persistable } = opts;
|
|
2521
|
+
await this.kernel.start({
|
|
2522
|
+
name: 'autoloop',
|
|
2523
|
+
cwd: opts.workspace,
|
|
2524
|
+
nodes: [
|
|
2525
|
+
{
|
|
2526
|
+
id: LEGACY_NODE,
|
|
2527
|
+
kind: 'autoloop',
|
|
2528
|
+
workspace: opts.workspace,
|
|
2529
|
+
config: persistable,
|
|
2530
|
+
},
|
|
2531
|
+
],
|
|
2532
|
+
}, {
|
|
2533
|
+
runId: opts.runId,
|
|
2534
|
+
cwd: opts.workspace,
|
|
2535
|
+
tag,
|
|
2536
|
+
secrets: { plannerCustomEngine, coderCustomEngine, reviewerCustomEngine },
|
|
2537
|
+
});
|
|
2511
2538
|
try {
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
workspace: opts.workspace,
|
|
2515
|
-
ledger_dir: ledgerDir,
|
|
2516
|
-
started_at: runner.state.started_at,
|
|
2517
|
-
planner_session: dispatcher.sessionNames.planner,
|
|
2518
|
-
planner_engine: plannerEngine,
|
|
2519
|
-
planner_model: opts.plannerModel,
|
|
2520
|
-
coder_engine: coderEngine,
|
|
2521
|
-
coder_model: opts.coderModel,
|
|
2522
|
-
reviewer_engine: reviewerEngine,
|
|
2523
|
-
reviewer_model: opts.reviewerModel,
|
|
2524
|
-
});
|
|
2539
|
+
const { plannerSession, state } = await ready;
|
|
2540
|
+
return { runId: opts.runId, plannerSession, state };
|
|
2525
2541
|
}
|
|
2526
2542
|
catch (err) {
|
|
2527
|
-
|
|
2543
|
+
// A start that never came up must not leave the id claimed. The store
|
|
2544
|
+
// refuses to reuse a run id, so without this a failed Planner startup
|
|
2545
|
+
// would make that id permanently unusable.
|
|
2546
|
+
//
|
|
2547
|
+
// Tag-guarded: by the time this runs, a retry may already hold the id, and
|
|
2548
|
+
// deleting it would take out the run that replaced us.
|
|
2549
|
+
this.kernel.delete(opts.runId, { expectTag: tag });
|
|
2550
|
+
throw err;
|
|
2551
|
+
}
|
|
2552
|
+
finally {
|
|
2553
|
+
this._autoloopReady.delete(tag);
|
|
2554
|
+
this._autoloopStarting.delete(tag);
|
|
2528
2555
|
}
|
|
2529
|
-
return {
|
|
2530
|
-
runId: opts.runId,
|
|
2531
|
-
plannerSession: dispatcher.sessionNames.planner,
|
|
2532
|
-
state: runner.state,
|
|
2533
|
-
};
|
|
2534
2556
|
}
|
|
2535
2557
|
/**
|
|
2536
2558
|
* Inject a user chat message into a v2 run's Planner. Returns the Planner's
|
|
2537
2559
|
* natural-language reply.
|
|
2538
2560
|
*/
|
|
2539
2561
|
async autoloopChat(runId, text) {
|
|
2540
|
-
const ctx = this.
|
|
2541
|
-
if (!ctx || this._deletingAutoloops.has(runId))
|
|
2542
|
-
throw new Error(`Autoloop run '${runId}' not found`);
|
|
2562
|
+
const ctx = this._liveAutoloop(runId);
|
|
2543
2563
|
let reply = '';
|
|
2544
2564
|
const onReply = (...args) => {
|
|
2545
2565
|
const t = args[0];
|
|
@@ -2555,74 +2575,54 @@ export class SessionManager {
|
|
|
2555
2575
|
}
|
|
2556
2576
|
return { reply };
|
|
2557
2577
|
}
|
|
2578
|
+
/**
|
|
2579
|
+
* The running loop for a run, or a clear reason why there is not one.
|
|
2580
|
+
*
|
|
2581
|
+
* Chatting with a Planner needs the live dispatcher; a run that finished or
|
|
2582
|
+
* belongs to another process has a readable record and no one to talk to.
|
|
2583
|
+
*/
|
|
2584
|
+
_liveAutoloop(runId) {
|
|
2585
|
+
const handle = this.kernel.handle(runId, LEGACY_NODE);
|
|
2586
|
+
if (handle)
|
|
2587
|
+
return handle;
|
|
2588
|
+
const record = loadRun(runId);
|
|
2589
|
+
if (!record || record.workflow !== 'autoloop')
|
|
2590
|
+
throw new Error(`Autoloop run '${runId}' not found`);
|
|
2591
|
+
throw new Error(`Autoloop run '${runId}' is ${record.state} and not running in this process — resume it before chatting`);
|
|
2592
|
+
}
|
|
2558
2593
|
autoloopStatus(runId) {
|
|
2559
|
-
const live = this.
|
|
2594
|
+
const live = this.kernel.handle(runId, LEGACY_NODE)?.runner.state;
|
|
2560
2595
|
if (live)
|
|
2561
2596
|
return live;
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
const
|
|
2566
|
-
if (!
|
|
2597
|
+
// Not running here. The record still holds the last state the loop
|
|
2598
|
+
// published, so a historical run opens with its real iteration count and
|
|
2599
|
+
// workspace instead of the all-zero stub the registry fallback produced.
|
|
2600
|
+
const record = loadRun(runId);
|
|
2601
|
+
if (!record || record.workflow !== 'autoloop')
|
|
2567
2602
|
return undefined;
|
|
2568
|
-
return
|
|
2569
|
-
run_id: entry.run_id,
|
|
2570
|
-
status: 'terminated',
|
|
2571
|
-
iter: 0,
|
|
2572
|
-
subagents_spawned: false,
|
|
2573
|
-
started_at: entry.started_at,
|
|
2574
|
-
workspace: entry.workspace,
|
|
2575
|
-
ledger_dir: entry.ledger_dir,
|
|
2576
|
-
push_log_count: 0,
|
|
2577
|
-
status_reason: 'reconstructed from registry — not in current process memory',
|
|
2578
|
-
consecutive_phase_errors: 0,
|
|
2579
|
-
recent_phase_errors: [],
|
|
2580
|
-
metric_history: [],
|
|
2581
|
-
last_activity_at: 0,
|
|
2582
|
-
};
|
|
2603
|
+
return autoloopStateFromRecord(record);
|
|
2583
2604
|
}
|
|
2584
2605
|
autoloopList() {
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
.filter((
|
|
2589
|
-
.map((e) => ({
|
|
2590
|
-
run_id: e.run_id,
|
|
2591
|
-
status: 'terminated',
|
|
2592
|
-
iter: 0,
|
|
2593
|
-
subagents_spawned: false,
|
|
2594
|
-
started_at: e.started_at,
|
|
2595
|
-
workspace: e.workspace,
|
|
2596
|
-
ledger_dir: e.ledger_dir,
|
|
2597
|
-
push_log_count: 0,
|
|
2598
|
-
status_reason: 'reconstructed from registry — not in current process memory',
|
|
2599
|
-
consecutive_phase_errors: 0,
|
|
2600
|
-
recent_phase_errors: [],
|
|
2601
|
-
metric_history: [],
|
|
2602
|
-
last_activity_at: 0,
|
|
2603
|
-
}));
|
|
2604
|
-
return [...inMemory, ...fromDisk].sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''));
|
|
2606
|
+
return this.kernel
|
|
2607
|
+
.list({ workflow: 'autoloop' })
|
|
2608
|
+
.map((r) => this.autoloopStatus(r.runId))
|
|
2609
|
+
.filter((s) => Boolean(s));
|
|
2605
2610
|
}
|
|
2606
|
-
/**
|
|
2607
|
-
* Reset a single subagent on a v2 run. Useful when an agent has drifted
|
|
2608
|
-
* (chat memory implies hallucination, repeated rejects, or context bloat).
|
|
2609
|
-
* Coder/Reviewer: safe to reset; the next directive/review_request will
|
|
2610
|
-
* re-prime from system prompt + ledger artifacts.
|
|
2611
|
-
* Planner: requires force=true and discards user-conversation context.
|
|
2612
|
-
*/
|
|
2613
2611
|
async autoloopResetAgent(runId, agent, opts = {}) {
|
|
2614
|
-
const ctx = this.
|
|
2612
|
+
const ctx = this.kernel.handle(runId, LEGACY_NODE);
|
|
2615
2613
|
if (!ctx)
|
|
2616
2614
|
return false;
|
|
2617
2615
|
await ctx.dispatcher.resetAgent(agent, opts);
|
|
2618
2616
|
return true;
|
|
2619
2617
|
}
|
|
2620
2618
|
async autoloopStop(runId, reason = 'user-stop') {
|
|
2621
|
-
const ctx = this.
|
|
2619
|
+
const ctx = this.kernel.handle(runId, LEGACY_NODE);
|
|
2622
2620
|
if (!ctx)
|
|
2623
2621
|
return false;
|
|
2622
|
+
// Soft stop: a terminate envelope, so the three persistent agents shut down
|
|
2623
|
+
// and the persisted sessions survive for a later resume. The node's exit
|
|
2624
|
+
// watcher sees the status change and lets the run finish on its own.
|
|
2624
2625
|
await ctx.runner.send(AutoloopMsg.terminate(ctx.runner.state.iter, { reason }));
|
|
2625
|
-
this.autoloops.delete(runId);
|
|
2626
2626
|
return true;
|
|
2627
2627
|
}
|
|
2628
2628
|
/**
|
|
@@ -2642,62 +2642,113 @@ export class SessionManager {
|
|
|
2642
2642
|
* is still served via /autoloop/<id>/chat_history so the dashboard can
|
|
2643
2643
|
* replay the conversation visually.
|
|
2644
2644
|
*/
|
|
2645
|
+
/**
|
|
2646
|
+
* Which roles of a stored autoloop run need a custom-engine config before it
|
|
2647
|
+
* can be resumed.
|
|
2648
|
+
*
|
|
2649
|
+
* Custom-engine configs are never persisted, so a resume has to be given them
|
|
2650
|
+
* again — and a caller that cannot find out which roles need one can only
|
|
2651
|
+
* guess. The dashboard's Resume button used to send an empty body
|
|
2652
|
+
* unconditionally, which meant a custom-engine run could be resumed from the
|
|
2653
|
+
* library and from the HTTP API but not from the UI that offers the button.
|
|
2654
|
+
*
|
|
2655
|
+
* Returns role names only. Nothing here is sensitive: the engine kind is
|
|
2656
|
+
* already in `spec.json`, and the answer is a list of roles, not credentials.
|
|
2657
|
+
*/
|
|
2658
|
+
autoloopResumeRequirements(runId) {
|
|
2659
|
+
const record = loadRun(runId);
|
|
2660
|
+
if (!record || record.workflow !== 'autoloop')
|
|
2661
|
+
throw new Error(`Autoloop run '${runId}' not found`);
|
|
2662
|
+
const config = record.spec.nodes.find((n) => n.id === LEGACY_NODE)
|
|
2663
|
+
?.config;
|
|
2664
|
+
const roles = [];
|
|
2665
|
+
for (const role of ['planner', 'coder', 'reviewer']) {
|
|
2666
|
+
if (config?.[`${role}Engine`] === 'custom')
|
|
2667
|
+
roles.push(role);
|
|
2668
|
+
}
|
|
2669
|
+
return { runId, rolesNeedingCustomEngine: roles };
|
|
2670
|
+
}
|
|
2645
2671
|
async autoloopResume(runId, opts = {}) {
|
|
2646
|
-
const
|
|
2647
|
-
if (
|
|
2648
|
-
return
|
|
2649
|
-
const
|
|
2650
|
-
if (!
|
|
2651
|
-
throw new Error(`Autoloop run '${runId}' not found
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2672
|
+
const live = this.kernel.handle(runId, LEGACY_NODE);
|
|
2673
|
+
if (live)
|
|
2674
|
+
return live.runner.state;
|
|
2675
|
+
const record = loadRun(runId);
|
|
2676
|
+
if (!record || record.workflow !== 'autoloop')
|
|
2677
|
+
throw new Error(`Autoloop run '${runId}' not found`);
|
|
2678
|
+
const config = record.spec.nodes.find((n) => n.id === LEGACY_NODE)
|
|
2679
|
+
?.config;
|
|
2680
|
+
if (!config)
|
|
2681
|
+
throw new Error(`Autoloop run '${runId}' has no stored configuration to restart from`);
|
|
2682
|
+
// Validate the full restart configuration before touching anything. The
|
|
2683
|
+
// spec is the immutable record of how the run was started, so a resume
|
|
2684
|
+
// reproduces it exactly instead of reconstructing it from a registry row
|
|
2685
|
+
// whose older versions omitted the engine fields entirely.
|
|
2686
|
+
validateAutoloopRole('planner', config.plannerEngine, opts.plannerCustomEngine);
|
|
2687
|
+
validateAutoloopRole('coder', config.coderEngine, opts.coderCustomEngine);
|
|
2688
|
+
validateAutoloopRole('reviewer', config.reviewerEngine, opts.reviewerCustomEngine);
|
|
2689
|
+
// Custom-engine configs are never persisted (they can carry secrets), so a
|
|
2690
|
+
// resume must be given them again by the caller.
|
|
2691
|
+
const resumed = await this._resumeAutoloopRun(runId, {
|
|
2692
|
+
...config,
|
|
2665
2693
|
plannerCustomEngine: opts.plannerCustomEngine,
|
|
2666
|
-
coderEngine,
|
|
2667
|
-
coderModel: entry.coder_model,
|
|
2668
2694
|
coderCustomEngine: opts.coderCustomEngine,
|
|
2669
|
-
reviewerEngine,
|
|
2670
|
-
reviewerModel: entry.reviewer_model,
|
|
2671
2695
|
reviewerCustomEngine: opts.reviewerCustomEngine,
|
|
2672
|
-
})
|
|
2696
|
+
});
|
|
2697
|
+
return resumed;
|
|
2698
|
+
}
|
|
2699
|
+
/** Re-attach a stored autoloop run: same run id, same spec, fresh engine. */
|
|
2700
|
+
async _resumeAutoloopRun(runId, config) {
|
|
2701
|
+
const tag = `${runId}:${randomUUID()}`;
|
|
2702
|
+
const ready = new Promise((resolve, reject) => {
|
|
2703
|
+
this._autoloopReady.set(tag, { resolve, reject });
|
|
2704
|
+
});
|
|
2705
|
+
this._autoloopStarting.set(tag, runId);
|
|
2706
|
+
// The custom-engine configs the caller re-supplied go into the run's secret
|
|
2707
|
+
// bag, which is where the node reads them from. They used to be stashed in a
|
|
2708
|
+
// separate map the executor no longer consulted, so a resume in a fresh
|
|
2709
|
+
// process — the case that matters — silently got none of them.
|
|
2710
|
+
const secrets = {
|
|
2711
|
+
plannerCustomEngine: config.plannerCustomEngine,
|
|
2712
|
+
coderCustomEngine: config.coderCustomEngine,
|
|
2713
|
+
reviewerCustomEngine: config.reviewerCustomEngine,
|
|
2714
|
+
};
|
|
2715
|
+
try {
|
|
2716
|
+
// `restart: true` because an autoloop resume means "bring the loop back
|
|
2717
|
+
// up", not "carry on from where the kernel left off" — the run is
|
|
2718
|
+
// normally terminated when someone resumes it.
|
|
2719
|
+
const record = await this.kernel.resume(runId, { restart: true, secrets, tag });
|
|
2720
|
+
// Race readiness against the run ending: a node that fails before it
|
|
2721
|
+
// publishes would otherwise leave this awaiting a signal that is never
|
|
2722
|
+
// coming.
|
|
2723
|
+
const finished = this.kernel
|
|
2724
|
+
.wait(record.runId)
|
|
2725
|
+
.then((r) => Promise.reject(new Error(r?.error ?? `autoloop run '${runId}' ended before it came up`)));
|
|
2726
|
+
const { state } = await Promise.race([ready, finished]);
|
|
2727
|
+
return state;
|
|
2728
|
+
}
|
|
2729
|
+
finally {
|
|
2730
|
+
this._autoloopReady.delete(tag);
|
|
2731
|
+
this._autoloopStarting.delete(tag);
|
|
2732
|
+
}
|
|
2673
2733
|
}
|
|
2674
2734
|
/**
|
|
2675
|
-
* Delete a run
|
|
2676
|
-
* process, then scrub the row from the cross-process registry so it stops
|
|
2677
|
-
* appearing in `autoloop_list` / the dashboard. The ledger directory on disk
|
|
2678
|
-
* is NOT removed — postmortem artifacts (chat history, push log, plan.md)
|
|
2679
|
-
* are kept for the user to inspect or `rm` manually.
|
|
2735
|
+
* Delete a run: really gone, not paused.
|
|
2680
2736
|
*
|
|
2681
|
-
*
|
|
2737
|
+
* The two `Set` fences this used to open with — one refusing a delete while a
|
|
2738
|
+
* start was in flight, one blocking a concurrent start during the async
|
|
2739
|
+
* teardown — protected a shared `Map` that no longer exists. Cancelling the
|
|
2740
|
+
* run is what stops it, and the run store refuses to recreate a live id.
|
|
2682
2741
|
*/
|
|
2683
2742
|
async autoloopDelete(runId) {
|
|
2684
2743
|
// Refuse to tear down a run that is still coming up: its Planner session is
|
|
2685
|
-
// mid-startSession, so deleting now would drop the
|
|
2686
|
-
//
|
|
2687
|
-
|
|
2744
|
+
// mid-startSession, so deleting now would drop the run and orphan a session
|
|
2745
|
+
// that finishes starting a moment later. `_autoloopReady` holds an entry for
|
|
2746
|
+
// exactly the window between "run created" and "engine up", which is the
|
|
2747
|
+
// window that used to need a dedicated `_startingAutoloops` Set.
|
|
2748
|
+
if ([...this._autoloopStarting.values()].includes(runId)) {
|
|
2688
2749
|
throw new Error(`Autoloop with id '${runId}' is still starting`);
|
|
2689
2750
|
}
|
|
2690
|
-
|
|
2691
|
-
this._deletingAutoloops.add(runId);
|
|
2692
|
-
try {
|
|
2693
|
-
return await this._autoloopDeleteInner(runId);
|
|
2694
|
-
}
|
|
2695
|
-
finally {
|
|
2696
|
-
this._deletingAutoloops.delete(runId);
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
async _autoloopDeleteInner(runId) {
|
|
2700
|
-
const ctx = this.autoloops.get(runId);
|
|
2751
|
+
const ctx = this.kernel.handle(runId, LEGACY_NODE);
|
|
2701
2752
|
let touched = false;
|
|
2702
2753
|
if (ctx) {
|
|
2703
2754
|
// Delete = "really gone". Call dispatcher.shutdown directly with
|
|
@@ -2719,7 +2770,7 @@ export class SessionManager {
|
|
|
2719
2770
|
catch {
|
|
2720
2771
|
/* runner may already be stopped */
|
|
2721
2772
|
}
|
|
2722
|
-
this.
|
|
2773
|
+
this.kernel.cancel(runId);
|
|
2723
2774
|
touched = true;
|
|
2724
2775
|
}
|
|
2725
2776
|
else {
|
|
@@ -2736,22 +2787,20 @@ export class SessionManager {
|
|
|
2736
2787
|
this.persistedSessions.delete(`autoloop-${runId}-reviewer`);
|
|
2737
2788
|
savePersistedSessions(this.persistedSessions, this.logger);
|
|
2738
2789
|
}
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2790
|
+
// No registry to scrub: the run record IS the registry, and removing it is
|
|
2791
|
+
// the delete. The ledger directory under tasks/<runId>/ is deliberately left
|
|
2792
|
+
// alone — postmortem artifacts (chat history, push log, plan.md) outlive the
|
|
2793
|
+
// run, exactly as before.
|
|
2794
|
+
if (loadRun(runId)) {
|
|
2795
|
+
this.kernel.delete(runId);
|
|
2796
|
+
touched = true;
|
|
2746
2797
|
}
|
|
2747
2798
|
return touched;
|
|
2748
2799
|
}
|
|
2749
|
-
/** Used by embedded-server to attach SSE listeners. */
|
|
2800
|
+
/** Used by embedded-server to attach SSE listeners. Live runs only. */
|
|
2750
2801
|
getAutoloop(runId) {
|
|
2751
|
-
const
|
|
2752
|
-
|
|
2753
|
-
return undefined;
|
|
2754
|
-
return { runner: ctx.runner, dispatcher: ctx.dispatcher };
|
|
2802
|
+
const handle = this.kernel.handle(runId, LEGACY_NODE);
|
|
2803
|
+
return handle ? { runner: handle.runner, dispatcher: handle.dispatcher } : undefined;
|
|
2755
2804
|
}
|
|
2756
2805
|
_cleanupIdleSessions() {
|
|
2757
2806
|
const ttlMs = this.pluginConfig.sessionTtlMinutes * 60_000;
|