@volter/supercode-orchestrator 0.5.73 → 0.5.75
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/agents.mjs +34 -24
- package/bin/orchestrator.mjs +2 -0
- package/board/agent-declarations.mjs +5 -5
- package/board/ci.mjs +98 -102
- package/board/cli.mjs +44 -57
- package/board/dispatch.mjs +314 -190
- package/board/engine.mjs +50 -28
- package/board/git-document.mjs +68 -20
- package/board/machine-fleet.mjs +27 -14
- package/board/runtime-door.mjs +17 -14
- package/board/store.mjs +1 -1
- package/board/tools.mjs +2 -2
- package/board/workflow.mjs +13 -12
- package/board/workspace.mjs +22 -15
- package/board/ztrack.mjs +22 -12
- package/hermes-cli.mjs +4 -12
- package/loop.mjs +7 -7
- package/package.json +2 -2
- package/bin/machine-fleet.mjs +0 -38
- package/bin/runtime-door.mjs +0 -23
package/board/dispatch.mjs
CHANGED
|
@@ -28,9 +28,9 @@ import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, review
|
|
|
28
28
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
29
29
|
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, agentPreamble, agentEnvironment, recordAgentRun, activeAgentAssignments,finishAgentAssignment,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
30
30
|
import { materializeAgentHome } from '../agent-home.mjs';
|
|
31
|
-
import {
|
|
31
|
+
import { memorySnapshot } from '../hermes-cli.mjs';
|
|
32
32
|
import { createRequire } from 'node:module';
|
|
33
|
-
import { spawn as spawnChild,
|
|
33
|
+
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
34
34
|
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
35
35
|
import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
36
36
|
import { basename, dirname, join } from 'node:path';
|
|
@@ -81,14 +81,27 @@ export function commandEnv(as = null) {
|
|
|
81
81
|
return { ...process.env, ...(as ? { SUPERCODE_CALLER: as } : {}) };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* A `supercode` command the board runs as itself (a card's pane started or closed: `open … --pane`, `close`), started
|
|
86
|
+
* and awaited without holding this process's event loop: the owner answers verbs while it runs. `as`: the board this
|
|
87
|
+
* command acts for, so a card's pane is started and closed by its board, whichever session ran the board's verb.
|
|
88
|
+
* (Gap A31: these two are still helper processes; the daemon's launch and close doors replace them.)
|
|
89
|
+
*/
|
|
84
90
|
function sc(args, { input = null, timeout = 120_000, as = null } = {}) {
|
|
85
|
-
// `as`: the board this
|
|
86
|
-
// command acts for, so a card's pane is started and closed by its board, whichever session ran the board's verb
|
|
87
91
|
const env = commandEnv(as);
|
|
88
92
|
const started = performance.now();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let stdout = '', stderr = '', done = false;
|
|
95
|
+
const child = spawnChild(supercodeBin(), args, { cwd: tmpdir(), env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
96
|
+
const finish = (code, extra = '') => { if (done) return; done = true; clearTimeout(timer); noteStep(`supercode ${args[0]}`, performance.now() - started); resolve({ code, stdout, stderr: stderr + extra }); };
|
|
97
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); finish(null, `supercode ${args[0]} did not answer within ${timeout} ms`); }, timeout);
|
|
98
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
|
|
99
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });
|
|
100
|
+
child.on('error', (error) => finish(null, error.message));
|
|
101
|
+
child.on('close', (code) => finish(code));
|
|
102
|
+
child.stdin.on('error', () => {});
|
|
103
|
+
child.stdin.end(input ?? undefined);
|
|
104
|
+
});
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
const lastJson = (text) => {
|
|
@@ -122,7 +135,7 @@ export function callerSession() {
|
|
|
122
135
|
|
|
123
136
|
/** The fleet as the home's machines answer for themselves (machine-fleet.mjs): this machine, and every machine an open
|
|
124
137
|
* card of the home names or one of its notice subscribers is on. */
|
|
125
|
-
export function observeFleet(root = fleetRoot) {
|
|
138
|
+
export async function observeFleet(root = fleetRoot) {
|
|
126
139
|
const machines = new Set();
|
|
127
140
|
for (const slug of root ? listBoards(root) : []) {
|
|
128
141
|
try {
|
|
@@ -134,7 +147,7 @@ export function observeFleet(root = fleetRoot) {
|
|
|
134
147
|
} finally { b.close(); }
|
|
135
148
|
} catch { /* its own tick reports it */ }
|
|
136
149
|
}
|
|
137
|
-
const fleet = readMachineFleet(machines, localMachine());
|
|
150
|
+
const fleet = await readMachineFleet(machines, localMachine());
|
|
138
151
|
presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), unknown: fleet.unknown ?? new Set() };
|
|
139
152
|
notePresence(presenceReading);
|
|
140
153
|
return fleet;
|
|
@@ -155,7 +168,6 @@ const HARNESSES = {
|
|
|
155
168
|
const doorTransient = (r) => r.code === null || ['unavailable', 'machine_offline', 'rate_limited', 504, '504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'].includes(json(String(r.stderr ?? '').trim().split('\n').pop(), null)?.code);
|
|
156
169
|
const adoptedOnly = (s) => s?.mode === 'adopt' && !s.launch_key && s.surface !== 'headless';
|
|
157
170
|
const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.machine}:${s.harness ?? 'claude-code'}:${s.session_id}` : null);
|
|
158
|
-
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
159
171
|
|
|
160
172
|
// A session in a live pane of its machine is the fleet's reading; any other run is read on its own machine: a headless
|
|
161
173
|
// worker by its pid, a pane by that machine's pane list, a session in no pane (an adopted native one) by that machine's
|
|
@@ -163,8 +175,16 @@ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0,
|
|
|
163
175
|
const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ? fleet.native.get(addressOf(session)).alive : unpublishedAlive(session);
|
|
164
176
|
/** A subscriber's address as the session it names, for sessionAlive (its machine, harness and native id). */
|
|
165
177
|
const sessionAt = (address) => { const m = /^sc:([^:]+):([^:]+):(.+)$/.exec(String(address)); return m && HARNESSES[m[2]] ? { address, machine: normalMachine(m[1]), harness: m[2], session_id: m[3] } : null; };
|
|
166
|
-
|
|
178
|
+
/** A subscriber's liveness, read first (readBefore): true, false, or null when it could not be read (unknown, never dead). */
|
|
179
|
+
async function subscriberAlive(fleet, address) {
|
|
180
|
+
if (fleet.alive.has(address)) return true;
|
|
181
|
+
const session = sessionAt(address);
|
|
182
|
+
if (!session) return false;
|
|
183
|
+
await readBefore(fleet, session);
|
|
184
|
+
return sessionAlive(fleet, session) ?? null;
|
|
185
|
+
}
|
|
167
186
|
const paneLists = new Map();
|
|
187
|
+
/** A session the fleet reading did not publish: alive by what was read of it before (readBefore), else unknown (null). */
|
|
168
188
|
function unpublishedAlive(session) {
|
|
169
189
|
if (!session) return null;
|
|
170
190
|
if (session.surface === 'headless' && session.pid) return pidAlive(session.pid);
|
|
@@ -173,50 +193,59 @@ function unpublishedAlive(session) {
|
|
|
173
193
|
if (!session.session_id) return null;
|
|
174
194
|
try { return nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).alive; } catch { return null; }
|
|
175
195
|
}
|
|
176
|
-
|
|
177
|
-
if (!entry || Date.now() - entry.at > READING_MS)
|
|
178
|
-
if (readingsOnly) return null;
|
|
179
|
-
const r = doorOn(session.machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
|
|
180
|
-
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
181
|
-
entry = { at: Date.now(), panes: Array.isArray(panes) ? panes : null };
|
|
182
|
-
paneLists.set(session.machine, entry);
|
|
183
|
-
}
|
|
196
|
+
const entry = paneLists.get(session.machine);
|
|
197
|
+
if (!entry || Date.now() - entry.at > READING_MS) return null;
|
|
184
198
|
return entry.panes ? entry.panes.some((p) => p.pane === session.pane && !p.dead) : null;
|
|
185
199
|
}
|
|
200
|
+
/** A machine's pane list, read through its door (one reading serves READING_MS). */
|
|
201
|
+
async function readPaneList(machine) {
|
|
202
|
+
const entry = paneLists.get(machine);
|
|
203
|
+
if (entry && Date.now() - entry.at <= READING_MS) return entry;
|
|
204
|
+
const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
|
|
205
|
+
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
206
|
+
const read = { at: Date.now(), panes: Array.isArray(panes) ? panes : null };
|
|
207
|
+
paneLists.set(machine, read);
|
|
208
|
+
return read;
|
|
209
|
+
}
|
|
186
210
|
|
|
187
211
|
/** Native activity comes from the machine's harness discovery, not catalogue age: each session the round reads is
|
|
188
212
|
* discovered by its id (about 0.2 s), once per round (tick clears them). Listing the machine's every session to find
|
|
189
213
|
* them (up to 2000, 1-6 s each) cost more than the sessions a board runs. */
|
|
190
214
|
const discoveries = new Map();
|
|
191
215
|
// A machine's pane list and harness discovery are read before a board write opens, never inside one: a door that
|
|
192
|
-
// takes seconds would hold the board's write lock past a worker's own command's busy wait.
|
|
193
|
-
//
|
|
216
|
+
// takes seconds would hold the board's write lock past a worker's own command's busy wait. Reading is async (readNative,
|
|
217
|
+
// readPaneList, readBefore: the doors in this process, never holding the event loop); every decision reads what was
|
|
218
|
+
// read (nativeSessions, unpublishedAlive, sessionAlive), and a reading not taken yet is unknown. One reading serves
|
|
219
|
+
// READING_MS.
|
|
194
220
|
const READING_MS = 15_000;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
221
|
+
const txRead = (board, fn) => tx(board, fn);
|
|
222
|
+
/** Take the readings a decision or a board write about `session` will need, before it. */
|
|
223
|
+
async function readBefore(fleet, session) {
|
|
224
|
+
if (!session?.machine || session.surface === 'headless') return;
|
|
225
|
+
if (session.session_id) try { await readNative(session.machine, session.harness ?? 'claude-code', session.session_id); } catch { /* unread: unknown */ }
|
|
226
|
+
if (session.pane && !fleet?.native?.has(addressOf(session))) await readPaneList(session.machine);
|
|
227
|
+
}
|
|
228
|
+
/** A session's native discovery as this round read it (readNative); throws (unavailable) when it was not read. */
|
|
203
229
|
function nativeSessions(machine, harness, sessionId) {
|
|
230
|
+
const seen = discoveries.get(nativeKey(machine, harness, sessionId));
|
|
231
|
+
if (seen) return seen.sessions;
|
|
232
|
+
throw unavailable('native liveness is read before it is used (readBefore), and was not');
|
|
233
|
+
}
|
|
234
|
+
/** Read a session's native discovery through its machine's door, once a round. */
|
|
235
|
+
async function readNative(machine, harness, sessionId) {
|
|
204
236
|
const key = nativeKey(machine, harness, sessionId), seen = discoveries.get(key);
|
|
205
237
|
if (seen) return seen.sessions;
|
|
206
|
-
|
|
207
|
-
const sessions = discoverNative(machine, harness, sessionId);
|
|
238
|
+
const sessions = await discoverNative(machine, harness, sessionId);
|
|
208
239
|
discoveries.set(key, { at: Date.now(), sessions });
|
|
209
240
|
return sessions;
|
|
210
241
|
}
|
|
211
242
|
// A native discovery is bounded as the fleet reading is: one that does not answer in time is unknown, read next round.
|
|
212
243
|
const NATIVE_DISCOVERY_MS = 15_000;
|
|
213
244
|
const nativeKey = (machine, harness, sessionId) => `${normalMachine(machine)}\u0000${harness}\u0000${sessionId}`;
|
|
214
|
-
function discoverNative(machine, harness, sessionId) {
|
|
245
|
+
async function discoverNative(machine, harness, sessionId) {
|
|
215
246
|
if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
|
|
216
|
-
//
|
|
217
|
-
const result =
|
|
218
|
-
? doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000 }, { timeout: NATIVE_DISCOVERY_MS })
|
|
219
|
-
: sc(['discover', '--on', machine, '--harness', harness, '--query', sessionId, '--limit', '2000'], { timeout: NATIVE_DISCOVERY_MS });
|
|
247
|
+
// the machine's own harness door, this one's or another's, as every other harness read here is
|
|
248
|
+
const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000 }, { timeout: NATIVE_DISCOVERY_MS });
|
|
220
249
|
const page = result.code === 0 ? lastJson(result.stdout) : null;
|
|
221
250
|
if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
222
251
|
return page.sessions;
|
|
@@ -299,27 +328,28 @@ const offlineMachine = (machine) => {
|
|
|
299
328
|
return m !== localMachine() && presenceReading?.known === true && !presenceReading.online.has(m);
|
|
300
329
|
};
|
|
301
330
|
/** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
|
|
302
|
-
const doorOn = (machine, method, params, options) => offlineMachine(machine)
|
|
331
|
+
const doorOn = async (machine, method, params, options) => offlineMachine(machine)
|
|
303
332
|
? { code: 1, stdout: '', stderr: JSON.stringify({ code: 'machine_offline', message: `machine ${normalMachine(machine)} did not answer the fleet reading` }) }
|
|
304
|
-
: runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, options);
|
|
333
|
+
: await runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, options);
|
|
305
334
|
const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
|
|
306
335
|
|
|
307
336
|
// ------------------------------------------------------------------ supervise
|
|
308
337
|
|
|
309
|
-
function supervise(board, fleet, settings, report, entry, effects) {
|
|
338
|
+
async function supervise(board, fleet, settings, report, entry, effects) {
|
|
310
339
|
const rows = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
311
340
|
// Published native observations are the only liveness input. Missing data remains unknown. A launch the
|
|
312
341
|
// observations show stopped is asked, through its machine's door, how it ended.
|
|
313
342
|
const exits = new Map();
|
|
343
|
+
// every running session's liveness is read before anything is decided of it (unknown stays unknown)
|
|
344
|
+
for (const { id } of rows) await readBefore(fleet, currentRun(board.db, id)?.metadata?.supercode);
|
|
314
345
|
for (const { id } of rows) {
|
|
315
346
|
const session = currentRun(board.db, id)?.metadata?.supercode;
|
|
316
347
|
if (!session?.launch_key || !fleet.ok || sessionAlive(fleet, session) !== false) continue;
|
|
317
348
|
// how a keyed launch ended, where its machine keeps that record: read when it can be, never waited on
|
|
318
349
|
if (!fleet.capabilities?.get(normalMachine(session.machine))?.launches) continue;
|
|
319
|
-
try { const receipt = launchReceipt(board, session); if (receipt?.live === false && receipt.exit) exits.set(id, { ...receipt.exit, launch_key: session.launch_key }); }
|
|
350
|
+
try { const receipt = await launchReceipt(board, session); if (receipt?.live === false && receipt.exit) exits.set(id, { ...receipt.exit, launch_key: session.launch_key }); }
|
|
320
351
|
catch (error) { report.failed.push([id, `exit receipt unavailable; retaining liveness observation: ${error.message}`]); }
|
|
321
352
|
}
|
|
322
|
-
for (const { id } of rows) readBefore(fleet, currentRun(board.db, id)?.metadata?.supercode);
|
|
323
353
|
for (const { id } of rows) {
|
|
324
354
|
// A transaction that meets another round's write runs again from its start: what it asks for is staged per attempt
|
|
325
355
|
// and kept only once it committed, so a second round never carries out a move that was rolled back.
|
|
@@ -490,29 +520,29 @@ function limitsOf(db, board, entry, task, session) {
|
|
|
490
520
|
return out;
|
|
491
521
|
}
|
|
492
522
|
|
|
493
|
-
function stopSession(board, session) {
|
|
523
|
+
async function stopSession(board, session) {
|
|
494
524
|
if (!session) return;
|
|
495
525
|
if (boardCopyOf(board)) throw copyRefusal(board, 'closes no session');
|
|
496
526
|
if (session.surface === 'headless' && pidAlive(session.pid)) {
|
|
497
527
|
// SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
|
|
498
528
|
const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
|
|
499
529
|
signal('SIGTERM');
|
|
500
|
-
for (let waited = 0; waited < 10 && pidAlive(session.pid); waited += 1)
|
|
530
|
+
for (let waited = 0; waited < 10 && pidAlive(session.pid); waited += 1) await new Promise((resolve) => setTimeout(resolve, 500));
|
|
501
531
|
if (pidAlive(session.pid)) signal('SIGKILL');
|
|
502
532
|
return;
|
|
503
533
|
}
|
|
504
534
|
if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
|
|
505
535
|
if (!session.pane && session.surface === 'native') {
|
|
506
|
-
const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
|
|
536
|
+
const r = await sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
|
|
507
537
|
if (r.code !== 0) throw new Error(`${doorTransient(r) ? 'close deferred: ' : ''}closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
508
538
|
// the close's receipt is a discovery taken after it, never the round's cached one from before
|
|
509
539
|
discoveries.delete(nativeKey(session.machine, session.harness, session.session_id));
|
|
510
|
-
const reading = nativeReading(
|
|
540
|
+
const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id), session);
|
|
511
541
|
if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
|
|
512
542
|
return;
|
|
513
543
|
}
|
|
514
544
|
if (session.pane) {
|
|
515
|
-
const r = runtimeDoor(normalMachine(session.machine??localMachine())===localMachine()?null:session.machine,'harness.v1.panes.close',{pane:session.pane},{env:commandEnv(),timeout:60_000,as:boardAddress(board.label)});
|
|
545
|
+
const r = await runtimeDoor(normalMachine(session.machine??localMachine())===localMachine()?null:session.machine,'harness.v1.panes.close',{pane:session.pane},{env:commandEnv(),timeout:60_000,as:boardAddress(board.label)});
|
|
516
546
|
// a door that timed out or could not be reached says nothing about the pane: the close waits for the next round
|
|
517
547
|
const transient = doorTransient(r);
|
|
518
548
|
if (r.code !== 0 || json(r.stdout.trim(), null)?.closed !== session.pane) throw new Error(`${transient ? 'close deferred: ' : ''}closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
|
|
@@ -531,7 +561,7 @@ export function whereOf(board, task) {
|
|
|
531
561
|
const hostOf = (pressure) => ({ memory_pressure: pressure, max_in_progress: derivedMaxInProgress() });
|
|
532
562
|
|
|
533
563
|
/** What every board of the home at `root` holds now, counted for its caps (capacity.mjs). */
|
|
534
|
-
function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
564
|
+
async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
535
565
|
const live = (s) => fleet.sessions?.find((x) => x.address === addressOf(s)) ?? null;
|
|
536
566
|
const out = [];
|
|
537
567
|
for (const slug of slugs) {
|
|
@@ -539,6 +569,8 @@ function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
539
569
|
const b = openBoard(root, slug);
|
|
540
570
|
try {
|
|
541
571
|
b.slug ??= slug; b.label ??= boardLabel(root, slug);
|
|
572
|
+
// each run's session read before it is counted live or not (unknown stays unknown)
|
|
573
|
+
for (const r of b.db.prepare('SELECT r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id').all()) await readBefore(fleet, json(r.metadata, {})?.supercode);
|
|
542
574
|
const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
|
|
543
575
|
out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine() }));
|
|
544
576
|
}
|
|
@@ -597,7 +629,7 @@ function transcriptModel(session) {
|
|
|
597
629
|
* that matches a card must have room; capacity.mjs). Each is sent the start event (its validators are Hermes's respawn
|
|
598
630
|
* guard) and what its new status runs is launched. `held` is what the home holds (homeHoldings), shared by its boards.
|
|
599
631
|
*/
|
|
600
|
-
function startCards(board, fleet, lanes, settings, report, entry, effects, { only = null, held = [], dry = false } = {}) {
|
|
632
|
+
async function startCards(board, fleet, lanes, settings, report, entry, effects, { only = null, held = [], dry = false } = {}) {
|
|
601
633
|
const stop = paused(board.root);
|
|
602
634
|
if (stop) { report.paused = stop; return; }
|
|
603
635
|
const { workflow, params } = entry;
|
|
@@ -671,7 +703,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
671
703
|
}
|
|
672
704
|
let moved, checked;
|
|
673
705
|
try {
|
|
674
|
-
checked = checkWorkspace(board, task);
|
|
706
|
+
checked = await checkWorkspace(board, task);
|
|
675
707
|
moved = tx(board, (db) => {
|
|
676
708
|
// The claim: another round (a second serve, a one-shot dispatch) may have started this card, or others that count
|
|
677
709
|
// against the same caps, since this round read the board. Its starts are counted, and this card starts only if it
|
|
@@ -702,32 +734,75 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
702
734
|
effects.push(...moved.effects);
|
|
703
735
|
caps.take(waitingEntries[i]);
|
|
704
736
|
try {
|
|
705
|
-
const session = launchRun(board, entry, task.id, plan.lane, fleet, settings);
|
|
737
|
+
const session = await launchRun(board, entry, task.id, plan.lane, fleet, settings);
|
|
706
738
|
if (session) {
|
|
707
739
|
if (session.surface !== 'pane' || normalMachine(task.machine ?? '') === localMachine()) tickState.local_started += 1;
|
|
708
740
|
report.started.push([task.id, session.address ?? `pid ${session.pid}`, session.mode]);
|
|
709
741
|
}
|
|
710
742
|
} catch (error) {
|
|
711
|
-
|
|
743
|
+
// a launch whose run ended meanwhile is no failure of the card's current run (its successor): said, nothing fired
|
|
744
|
+
if (error.launchPending || error.launchSuperseded) { report.queued.push([task.id, error.message]); continue; }
|
|
712
745
|
const payload = { error: String(error.message).slice(-500), origin: originOf(error), ...(error.resumeFailed ? { resume_failed: true, ...error.resumeFailed } : {}) };
|
|
713
|
-
|
|
746
|
+
let r;
|
|
747
|
+
try { r = launchFailed(board, entry, task.id, error, payload); } catch { r = { effects: [] }; /* no transition for it */ }
|
|
748
|
+
if (!r) { report.queued.push([task.id, `run ${error.launchRun} was replaced before its launch failed: ${error.message}`]); continue; }
|
|
749
|
+
effects.push(...r.effects);
|
|
714
750
|
report.failed.push([task.id, String(error.message).slice(-300)]);
|
|
715
751
|
}
|
|
716
752
|
}
|
|
717
753
|
}
|
|
718
754
|
|
|
719
755
|
/** Launch what the card's status runs: its role's lane, in the run's session slot, told the run's prompt and doors. */
|
|
720
|
-
|
|
756
|
+
/** Whether `runId` is still card `taskId`'s current, unended run: a launch's permission, read in the write it is used in. */
|
|
757
|
+
function runCurrent(db, taskId, runId) {
|
|
758
|
+
return db.prepare('SELECT current_run_id FROM tasks WHERE id = ?').get(taskId)?.current_run_id === runId
|
|
759
|
+
&& db.prepare('SELECT ended_at FROM task_runs WHERE id = ?').get(runId)?.ended_at == null;
|
|
760
|
+
}
|
|
761
|
+
const superseded = (taskId, runId) => Object.assign(new Error(`run ${runId} of ${taskId} ended or was replaced while its launch was prepared; nothing was started`), { launchSuperseded: true });
|
|
762
|
+
/** A launch's preparation awaits (minting, workspace, doors) while the owner answers verbs: after each await, and right
|
|
763
|
+
* before it is issued, the run must still be the card's current, unended one (a cancel or restart meanwhile ends it). */
|
|
764
|
+
function assertRunCurrent(board, taskId, runId) {
|
|
765
|
+
if (!view(board, (db) => runCurrent(db, taskId, runId))) throw superseded(taskId, runId);
|
|
766
|
+
}
|
|
767
|
+
/** A write a launch makes for its run: made only while that run is still the card's current one, checked in the same
|
|
768
|
+
* write, so a superseded launch's late result never lands on its successor's card. */
|
|
769
|
+
function runTx(board, taskId, runId, fn) {
|
|
770
|
+
return tx(board, (db) => { if (!runCurrent(db, taskId, runId)) throw superseded(taskId, runId); return fn(db); });
|
|
771
|
+
}
|
|
772
|
+
/** A launch's failure, recorded against the run it attempted and only while that run is still the card's current one
|
|
773
|
+
* (checked in the write that records it): a superseded launch's failure is no failure of its successor. Null when it
|
|
774
|
+
* was superseded. */
|
|
775
|
+
function launchFailed(board, entry, id, error, payload) {
|
|
776
|
+
return tx(board, (db) => {
|
|
777
|
+
if (!runCurrent(db, id, error.launchRun)) return null;
|
|
778
|
+
if (monitorsRuns(entry)) { surfaceIncident(db, board, entry, id, 'spawn_failed', payload, error.launchRun); return { effects: [] }; }
|
|
779
|
+
return fire(db, board, entry, id, 'spawn_failed', { roles: ['platform'], payload });
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The card's current run's launch. Every error it ends with names that run (`launchRun`); one read when the run is no
|
|
784
|
+
* longer the card's current one is its supersession, whatever failed, so no caller reports it against the successor. */
|
|
785
|
+
export async function launchRun(board, entry, id, lane, fleet, settings) {
|
|
786
|
+
const runId = getTaskNow(board, id).current_run_id;
|
|
787
|
+
try { return await launchAttempt(board, entry, id, lane, fleet, settings, runId); }
|
|
788
|
+
catch (error) {
|
|
789
|
+
error.launchRun = runId;
|
|
790
|
+
if (!error.launchSuperseded && !view(board, (db) => runCurrent(db, id, runId))) error.launchSuperseded = true;
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
721
796
|
let task = getTaskNow(board, id);
|
|
722
|
-
|
|
723
|
-
|
|
797
|
+
if (task.current_run_id !== runId) throw superseded(id, runId);
|
|
798
|
+
const checked = await checkWorkspace(board, task);
|
|
799
|
+
if (checked) runTx(board, id, runId, (db) => {
|
|
724
800
|
assertWorkspaceOwner(db, task, checked);
|
|
725
801
|
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
|
|
726
802
|
});
|
|
727
803
|
const status = view(board, (db) => statusOf(db, task));
|
|
728
804
|
const spec = runSpec(entry, status, task);
|
|
729
805
|
if (!spec) return null;
|
|
730
|
-
const runId = task.current_run_id;
|
|
731
806
|
const surface = lane.surface === 'pane' ? 'pane' : 'headless';
|
|
732
807
|
const scope = view(board, (db) => scopeOf(db, board, task, { params: entry.params }));
|
|
733
808
|
const where = whereOf(board, task);
|
|
@@ -748,8 +823,9 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
748
823
|
// Teams is never on a launch's path: the card's agent is its identity minted on this host under its retained intent,
|
|
749
824
|
// and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
|
|
750
825
|
const context=current().facts.SUPERCODE_CONTEXT,team=agentScope(context);
|
|
751
|
-
const agent=localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
|
|
826
|
+
const agent=await localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
|
|
752
827
|
profile:lane.name,card:id,role:spec.role??slot,sponsor:task.sponsor??null});
|
|
828
|
+
assertRunCurrent(board,id,runId);
|
|
753
829
|
// G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
|
|
754
830
|
const budgets=[...(entry.profiles?.default?.budgets??[]),...(lane.name!=='default'?entry.profiles?.[lane.name]?.budgets??[]:[])];
|
|
755
831
|
const refused=budgets.length?runBudgetRefusal(board.root,agent.identity.id,budgets,new Date().toISOString()):null;
|
|
@@ -762,12 +838,14 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
762
838
|
// The agent's own memory reaches every session of it: headless runs open its home; a pane, which may sit on
|
|
763
839
|
// another machine, gets the home's snapshot in its first prompt. A shared profile's memory is never used.
|
|
764
840
|
const agentHome=materializeAgentHome(board.root,team.org,agent.identity.id,lane);
|
|
841
|
+
assertRunCurrent(board,id,runId);
|
|
765
842
|
if(surface==='headless')meta.agent_env.HERMES_HOME=agentHome;
|
|
766
|
-
else{const memory=
|
|
843
|
+
else{const memory=await memorySnapshot(agentHome).catch(()=>'');assertRunCurrent(board,id,runId);if(memory)prompt=`${prompt}\n\n${memory}`;}
|
|
767
844
|
const result = surface === 'pane'
|
|
768
|
-
? launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
|
|
769
|
-
: launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
|
|
845
|
+
? await launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
|
|
846
|
+
: await launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
|
|
770
847
|
tx(board,db=>{
|
|
848
|
+
if(!runCurrent(db,id,runId))return; // replaced once launched: its session is the ended run's, closed as one
|
|
771
849
|
setRunSession(db,runId,{launch_deferred:false,launch_deferred_error:null,restart_authorized:false});
|
|
772
850
|
event(db,id,'restart_authorization_consumed',{},runId);
|
|
773
851
|
});
|
|
@@ -777,12 +855,15 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
777
855
|
// An issued asynchronous launch may bind later. Its original permission
|
|
778
856
|
// still belongs to this attempt, never a future lost-session restart. A
|
|
779
857
|
// deferred launch was never issued: its run keeps the permission for its retry.
|
|
858
|
+
if(error.launchSuperseded||!view(board,db=>runCurrent(db,id,runId)))throw Object.assign(error,{launchSuperseded:true});
|
|
780
859
|
if(!error.launchDeferred)tx(board,db=>{
|
|
860
|
+
if(!runCurrent(db,id,runId))return;
|
|
781
861
|
setRunSession(db,runId,{restart_authorized:false});
|
|
782
862
|
event(db,id,'restart_authorization_consumed',{},runId);
|
|
783
863
|
});
|
|
784
864
|
if(error.launchDeferred) {
|
|
785
865
|
tx(board,db=>{
|
|
866
|
+
if(!runCurrent(db,id,runId))return;
|
|
786
867
|
const previous=currentRun(db,id)?.metadata?.supercode;
|
|
787
868
|
setRunSession(db,runId,{...meta,launch_deferred:true,launch_deferred_error:error.message});
|
|
788
869
|
if(previous?.launch_deferred_error!==error.message)event(db,id,'launch_deferred',{reason:error.message},runId);
|
|
@@ -799,8 +880,8 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
799
880
|
// slot's session checks that session through it
|
|
800
881
|
fleetRoot = board.root;
|
|
801
882
|
const entry = await loadWorkflow(board.root);
|
|
802
|
-
const fleet = observeFleet();
|
|
803
|
-
const before = getTaskNow(board, id), checked = checkWorkspace(board, before);
|
|
883
|
+
const fleet = await observeFleet();
|
|
884
|
+
const before = getTaskNow(board, id), checked = await checkWorkspace(board, before);
|
|
804
885
|
const moved = tx(board, (db) => {
|
|
805
886
|
assertWorkspaceOwner(db, before, checked);
|
|
806
887
|
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
|
|
@@ -817,9 +898,9 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
817
898
|
const lane = profile ? lanes[profile] : null;
|
|
818
899
|
if (spec && !lane) throw new Error(`${id} is ${moved.to}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
|
|
819
900
|
if (!lane) return null;
|
|
820
|
-
try { return launchRun(board,entry,id,lane,fleet,settings); }
|
|
901
|
+
try { return await launchRun(board,entry,id,lane,fleet,settings); }
|
|
821
902
|
catch (error) {
|
|
822
|
-
if (monitorsRuns(entry) && !error.launchPending)
|
|
903
|
+
if (monitorsRuns(entry) && !error.launchPending && !error.launchSuperseded) launchFailed(board, entry, id, error, { error: error.message, origin: originOf(error) });
|
|
823
904
|
throw error;
|
|
824
905
|
}
|
|
825
906
|
}
|
|
@@ -834,11 +915,11 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
834
915
|
// The programs a harness pane runs in its foreground: Claude Code's, Codex's, and node (the daemon's Codex pane host,
|
|
835
916
|
// a harness installed through npm). A pane running one of these runs a harness session, read or not.
|
|
836
917
|
const HARNESS_COMMANDS = new Set(['claude', 'claude.exe', 'codex', 'codex.exe', 'node']);
|
|
837
|
-
function turnAtClose(session, pane = null) {
|
|
918
|
+
async function turnAtClose(session, pane = null) {
|
|
838
919
|
const machine = session.machine ?? localMachine();
|
|
839
920
|
let activity;
|
|
840
921
|
if (pane?.pid) {
|
|
841
|
-
const r = doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid] }, { timeout: NATIVE_DISCOVERY_MS });
|
|
922
|
+
const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid] }, { timeout: NATIVE_DISCOVERY_MS });
|
|
842
923
|
const page = r.code === 0 ? lastJson(r.stdout) : null;
|
|
843
924
|
if (!Array.isArray(page?.activities)) return `the session in pane ${session.pane} could not be read just now (${(r.stderr || r.stdout).trim().slice(-160)})`;
|
|
844
925
|
activity = page.activities.find((item) => item.pid === pane.pid)?.activity ?? null;
|
|
@@ -852,7 +933,7 @@ function turnAtClose(session, pane = null) {
|
|
|
852
933
|
return `pane ${session.pane} names no process to read`;
|
|
853
934
|
} else {
|
|
854
935
|
if (!session.session_id) return 'its session is not known yet';
|
|
855
|
-
try { activity = nativeReading(discoverNative(machine, session.harness ?? 'claude-code', session.session_id), session).row?.activity ?? null; }
|
|
936
|
+
try { activity = nativeReading(await discoverNative(machine, session.harness ?? 'claude-code', session.session_id), session).row?.activity ?? null; }
|
|
856
937
|
catch (error) { return `its session could not be read just now (${String(error.message).slice(0, 160)})`; }
|
|
857
938
|
if (!activity) return `its session ${addressOf(session)} is not in its machine's discovery`;
|
|
858
939
|
}
|
|
@@ -862,7 +943,7 @@ function turnAtClose(session, pane = null) {
|
|
|
862
943
|
return `its session ${who} is in a turn (${activity.turn ?? 'unknown'}); it closes once the turn ends`;
|
|
863
944
|
}
|
|
864
945
|
|
|
865
|
-
export function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
946
|
+
export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
866
947
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
|
|
867
948
|
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
868
949
|
// Repair pre-transfer history once: only identities with an older unended reference need a write.
|
|
@@ -948,17 +1029,17 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
|
|
|
948
1029
|
try {
|
|
949
1030
|
const machine = session.machine ?? localMachine();
|
|
950
1031
|
if (session.surface === 'native' && !session.pane) {
|
|
951
|
-
const reading = nativeReading(
|
|
1032
|
+
const reading = nativeReading(await readNative(machine, session.harness, session.session_id), session);
|
|
952
1033
|
if (reading.alive == null) throw new Error(`close deferred: native liveness unknown for ${addressOf(session)}`);
|
|
953
1034
|
if (reading.alive) {
|
|
954
1035
|
const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
|
|
955
1036
|
if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
|
|
956
|
-
if (row.ended_at) { const inTurn = turnAtClose(session); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
|
|
957
|
-
stopSession(board, session);
|
|
1037
|
+
if (row.ended_at) { const inTurn = await turnAtClose(session); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
|
|
1038
|
+
await stopSession(board, session);
|
|
958
1039
|
}
|
|
959
1040
|
} else {
|
|
960
1041
|
if (!panes.has(machine)) {
|
|
961
|
-
const r = doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
|
|
1042
|
+
const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
|
|
962
1043
|
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
963
1044
|
panes.set(machine, Array.isArray(list) ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null }])) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
|
|
964
1045
|
}
|
|
@@ -970,8 +1051,8 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
|
|
|
970
1051
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
971
1052
|
// An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
|
|
972
1053
|
// already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
|
|
973
|
-
if (row.ended_at) { const inTurn = turnAtClose(session, observed.live.get(session.pane)); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
|
|
974
|
-
stopSession(board, session); observed.live.delete(session.pane);
|
|
1054
|
+
if (row.ended_at) { const inTurn = await turnAtClose(session, observed.live.get(session.pane)); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
|
|
1055
|
+
await stopSession(board, session); observed.live.delete(session.pane);
|
|
975
1056
|
}
|
|
976
1057
|
}
|
|
977
1058
|
} catch (e) {
|
|
@@ -1015,7 +1096,7 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
|
|
|
1015
1096
|
|
|
1016
1097
|
/** Quiet time is independent of dispatch cadence and the harness's busy label.
|
|
1017
1098
|
* Keep bounded readings on the run so an ordinary quiet stretch can be reviewed later. */
|
|
1018
|
-
function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
1099
|
+
async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
1019
1100
|
if (!fleet.ok) return;
|
|
1020
1101
|
// The published fleet says whether a session runs and its turn, not when it last wrote: that is the session's own
|
|
1021
1102
|
// record, read from its machine's discovery (once a round, only when a card needs it).
|
|
@@ -1027,7 +1108,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1027
1108
|
};
|
|
1028
1109
|
const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
|
|
1029
1110
|
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
1030
|
-
for (const { id } of running) { const session = currentRun(board.db, id)?.metadata?.supercode; readBefore(fleet, session); if (session?.session_id) updatedAt(session); }
|
|
1111
|
+
for (const { id } of running) { const session = currentRun(board.db, id)?.metadata?.supercode; await readBefore(fleet, session); if (session?.session_id) updatedAt(session); }
|
|
1031
1112
|
for (const { id } of running) {
|
|
1032
1113
|
let staged = [];
|
|
1033
1114
|
txRead(board, (db) => {
|
|
@@ -1070,8 +1151,8 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1070
1151
|
const held = live?.turn === 'needs_input' ? 'waiting_on_user' : live?.turn === 'working' && lastAt > 0 && observedAt - lastAt >= settings.idleQuiet ? 'working_silent' : null;
|
|
1071
1152
|
if (held) {
|
|
1072
1153
|
if (session.idle_observation?.decision === held && session.idle_observation.last_message_at_ms === reading.last_message_at_ms) return;
|
|
1073
|
-
|
|
1074
|
-
|
|
1154
|
+
// (its screen is not read: the call here named `panes.capture`, no machine door, and never answered)
|
|
1155
|
+
const screen = null;
|
|
1075
1156
|
const observation = retain(held, { screen });
|
|
1076
1157
|
const why = held === 'waiting_on_user' ? 'is waiting on its user (a question or a permission prompt)' : `has reported working without writing anything for ${Math.round((observedAt - lastAt) / 60)} min`;
|
|
1077
1158
|
const text = `${id}: its session ${addressOf(session)} ${why}; nothing it is sent reaches it until that is answered.${screen ? `\nIts screen:\n${screen}` : ''}`;
|
|
@@ -1130,7 +1211,7 @@ export async function sessionNotice(board, e) {
|
|
|
1130
1211
|
}
|
|
1131
1212
|
const address = e.address ?? addressOf(prior);
|
|
1132
1213
|
if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
1133
|
-
const filed = fileMail(board, address, e.text, effectMessageKey(e));
|
|
1214
|
+
const filed = await fileMail(board, address, e.text, effectMessageKey(e));
|
|
1134
1215
|
tx(board, (db) => {
|
|
1135
1216
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
1136
1217
|
if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
@@ -1147,7 +1228,7 @@ export async function sessionEffect(board, e) {
|
|
|
1147
1228
|
const address = addressOf(session);
|
|
1148
1229
|
if (!address) throw new Error('actor session identity unavailable');
|
|
1149
1230
|
// filed in the actor's mailbox, which delivers it
|
|
1150
|
-
const filed = fileMail(board, address, e.text, effectMessageKey(e));
|
|
1231
|
+
const filed = await fileMail(board, address, e.text, effectMessageKey(e));
|
|
1151
1232
|
if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
|
|
1152
1233
|
return true;
|
|
1153
1234
|
}
|
|
@@ -1178,7 +1259,7 @@ export async function mergeReviewed(board, e) {
|
|
|
1178
1259
|
for (const p of prs) {
|
|
1179
1260
|
if (Date.now() - began > MERGE_BUDGET_MS) throw new Error(`the merge attempt spent its ${MERGE_BUDGET_MS / 1000} s; the rest is merged next attempt`);
|
|
1180
1261
|
let answer;
|
|
1181
|
-
try { answer = mergeReviewedPullRequest(p.pr.url, p.commit); } catch (error) {
|
|
1262
|
+
try { answer = await mergeReviewedPullRequest(p.pr.url, p.commit); } catch (error) {
|
|
1182
1263
|
const reason = String(error.message).slice(-300);
|
|
1183
1264
|
tx(board, (db) => event(db, e.id, 'merge_pending', { pr: p.pr.url, reviewed: p.commit, reason }));
|
|
1184
1265
|
throw new Error(`merging ${p.pr.url} at ${p.commit}: ${reason}`);
|
|
@@ -1246,11 +1327,18 @@ const PR_URL = /https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i;
|
|
|
1246
1327
|
/**
|
|
1247
1328
|
* This machine's memory pressure: on Linux as Hermes reads it (MemAvailable); on macOS the kernel's own level
|
|
1248
1329
|
* (kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical); `unknown` elsewhere, which restricts nothing.
|
|
1330
|
+
* Read once per round (readMemoryPressure, awaited at the round's start) and held for that round's caps.
|
|
1249
1331
|
*/
|
|
1250
|
-
|
|
1332
|
+
let pressureReading = 'unknown';
|
|
1333
|
+
const memoryPressure = () => pressureReading;
|
|
1334
|
+
export async function readMemoryPressure() {
|
|
1335
|
+
pressureReading = await pressureNow();
|
|
1336
|
+
return pressureReading;
|
|
1337
|
+
}
|
|
1338
|
+
async function pressureNow() {
|
|
1251
1339
|
if (process.platform === 'darwin') {
|
|
1252
|
-
const
|
|
1253
|
-
const level = Number(String(
|
|
1340
|
+
const stdout = await new Promise((resolve) => execFile('sysctl', ['-n', 'kern.memorystatus_vm_pressure_level'], { encoding: 'utf8', timeout: 5_000 }, (_, out) => resolve(out ?? '')));
|
|
1341
|
+
const level = Number(String(stdout).trim());
|
|
1254
1342
|
return level >= 4 ? 'critical' : level >= 2 ? 'elevated' : level === 1 ? 'ok' : 'unknown';
|
|
1255
1343
|
}
|
|
1256
1344
|
try {
|
|
@@ -1305,8 +1393,8 @@ function launchReading(fleet, session) {
|
|
|
1305
1393
|
}
|
|
1306
1394
|
|
|
1307
1395
|
/** How a keyed launch ended, as its machine recorded it; read only where the machine has the door (supervise). */
|
|
1308
|
-
function launchReceipt(board, session) {
|
|
1309
|
-
const r = doorOn(session.machine, 'harness.v1.launches.get',{key:session.launch_key},
|
|
1396
|
+
async function launchReceipt(board, session) {
|
|
1397
|
+
const r = await doorOn(session.machine, 'harness.v1.launches.get',{key:session.launch_key},
|
|
1310
1398
|
{ timeout: 30_000, as: boardAddress(board.label) });
|
|
1311
1399
|
if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1312
1400
|
if (r.stdout.trim() === 'null') return null;
|
|
@@ -1336,7 +1424,7 @@ function bindLaunch(board, row, session, pane, sid) {
|
|
|
1336
1424
|
}
|
|
1337
1425
|
|
|
1338
1426
|
/** A fast worker can reach its first board door before open's reply. Bind only the daemon's keyed pane. */
|
|
1339
|
-
export function reconcileCallerLaunch(board, sid) {
|
|
1427
|
+
export async function reconcileCallerLaunch(board, sid) {
|
|
1340
1428
|
fleetRoot = board.root;
|
|
1341
1429
|
if (view(board, (db) => db.prepare(`SELECT 1 FROM task_runs r JOIN tasks t ON t.current_run_id=r.id
|
|
1342
1430
|
WHERE json_extract(r.metadata, '$.supercode.session_id')=? AND COALESCE(json_extract(r.metadata, '$.supercode.launch_pending'),0)=0`).get(sid))) return;
|
|
@@ -1350,7 +1438,7 @@ export function reconcileCallerLaunch(board, sid) {
|
|
|
1350
1438
|
return s?.machine === machine && s.harness === harness && (!s.session_id || s.session_id === sid);
|
|
1351
1439
|
});
|
|
1352
1440
|
if (!candidates.length) return;
|
|
1353
|
-
const pane = process.env.SUPERCODE_TEAMS_PANE || observeFleet().sessions?.find((s) => s.address === `sc:${machine}:${harness}:${sid}`)?.pane;
|
|
1441
|
+
const pane = process.env.SUPERCODE_TEAMS_PANE || (await observeFleet()).sessions?.find((s) => s.address === `sc:${machine}:${harness}:${sid}`)?.pane;
|
|
1354
1442
|
if (!pane) return;
|
|
1355
1443
|
// the caller's own pane (its environment, or this machine's reading) binds the run whose launch recorded that pane
|
|
1356
1444
|
for (const row of candidates) {
|
|
@@ -1362,7 +1450,7 @@ export function reconcileCallerLaunch(board, sid) {
|
|
|
1362
1450
|
/** The board's agent records: each run kept in the agent store, closed cards' assignments ended, declarations and ends
|
|
1363
1451
|
* handed to this machine's daemon, and usage read from each machine's own doors. No Teams call: the daemon declares and
|
|
1364
1452
|
* ends agents and binds their sessions (D137). It runs in the delivery pass (`deliver`), never in a dispatch round. */
|
|
1365
|
-
function synchronizeAgents(board, report) {
|
|
1453
|
+
async function synchronizeAgents(board, report) {
|
|
1366
1454
|
// A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
|
|
1367
1455
|
// A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
|
|
1368
1456
|
const agentRuns=[...new Map(view(board,db=>db.prepare("SELECT id,task_id,metadata,ended_at FROM task_runs WHERE json_extract(metadata,'$.supercode.agent_id') IS NOT NULL ORDER BY id").all())
|
|
@@ -1395,9 +1483,9 @@ function synchronizeAgents(board, report) {
|
|
|
1395
1483
|
if(!run?.ended_at||read>=5)continue;
|
|
1396
1484
|
const [harness,...rest]=row.session_ref.split(':'),machine=row.machine&&normalMachine(row.machine)!==localMachine()?row.machine:null;
|
|
1397
1485
|
// the usage door loads the session by its whole locator, which discovery names
|
|
1398
|
-
const sessionId=rest.join(':'),found=runtimeDoor(machine,'harness.v1.sessions.discover',{query:sessionId,harnesses:[harness],limit:200},{timeout:30_000});
|
|
1486
|
+
const sessionId=rest.join(':'),found=await runtimeDoor(machine,'harness.v1.sessions.discover',{query:sessionId,harnesses:[harness],limit:200},{timeout:30_000});
|
|
1399
1487
|
const locator=found.code===0?json(found.stdout.trim(),{})?.sessions?.find(item=>item.locator?.harness===harness&&item.locator?.session_id===sessionId)?.locator:null;
|
|
1400
|
-
const reply=locator?runtimeDoor(machine,'harness.v1.sessions.usage',{locator},{timeout:30_000}):{code:1};read++;
|
|
1488
|
+
const reply=locator?await runtimeDoor(machine,'harness.v1.sessions.usage',{locator},{timeout:30_000}):{code:1};read++;
|
|
1401
1489
|
if(reply.code===0)recordRunUsage(board.root,org,row.session_ref,json(reply.stdout.trim(),{})?.usage??[]);
|
|
1402
1490
|
// a session unreadable a day after its run ended is recorded as having no usage record, so it stops retrying
|
|
1403
1491
|
else if(now()-Number(run.ended_at)>86400)recordRunUsage(board.root,org,row.session_ref,[]);
|
|
@@ -1409,11 +1497,11 @@ function synchronizeAgents(board, report) {
|
|
|
1409
1497
|
if(task&&(task.archived||['done','cancelled'].includes(task.status)))finishAgentAssignment(board.root,assignment.org,assignment.id,`card ${assignment.target} ${task.archived?'archived':task.status}`);
|
|
1410
1498
|
}
|
|
1411
1499
|
// What a creating or closing verb could not hand on (no daemon running then) is handed on here.
|
|
1412
|
-
try{for(const pending of handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
|
|
1500
|
+
try{for(const pending of await handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
|
|
1413
1501
|
catch(error){report.queued.push(['agents',`agent hand-off waits: ${error.message}`]);}
|
|
1414
1502
|
}
|
|
1415
1503
|
|
|
1416
|
-
function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes) {
|
|
1504
|
+
async function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes) {
|
|
1417
1505
|
const deferred=view(board,db=>db.prepare("SELECT t.id,r.id run_id,COALESCE(json_extract(r.metadata,'$.supercode.launched_at'),r.started_at) launched_at FROM tasks t JOIN task_runs r ON r.id=t.current_run_id WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.launch_deferred')=1").all());
|
|
1418
1506
|
// A deferred launch was never issued (a reading or door was not yet available): it is the start its manager, creator
|
|
1419
1507
|
// or workflow asked for, carried out within the launch window; supervise surfaces it once when the window ends. The
|
|
@@ -1431,13 +1519,15 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1431
1519
|
const why=launcherCheck(board.db,task,lane,fleet);if(why){report.queued.push([id,why]);continue;}
|
|
1432
1520
|
const claimed=tx(board,db=>{if(!stillDeferred(db))return false;setRunSession(db,run_id,{launch_deferred:false});return true;});
|
|
1433
1521
|
if(!claimed){report.queued.push([id,'its launch was taken by another round']);continue;}
|
|
1434
|
-
const session=launchRun(board,entry,id,lane,fleet,settings);
|
|
1522
|
+
const session=await launchRun(board,entry,id,lane,fleet,settings);
|
|
1435
1523
|
if(session)report.started.push([id,session.address??session.session_id,session.mode]);
|
|
1436
1524
|
// nothing to launch now (its status runs nothing yet): the run waits deferred, as before the claim
|
|
1437
1525
|
else tx(board,db=>{const run=db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(run_id);const saved=json(run?.metadata,{})?.supercode??{};if(!saved.launch_key&&!saved.pid)setRunSession(db,run_id,{launch_deferred:true});});
|
|
1438
1526
|
}catch(error){
|
|
1439
|
-
if(error.launchPending){report.queued.push([id,error.message]);continue;}
|
|
1440
|
-
const moved=
|
|
1527
|
+
if(error.launchPending||error.launchSuperseded){report.queued.push([id,error.message]);continue;}
|
|
1528
|
+
const moved=launchFailed(board,entry,id,error,monitorsRuns(entry)?{error:error.message,origin:originOf(error)}:{error:error.message});
|
|
1529
|
+
if(!moved){report.queued.push([id,`run ${error.launchRun} was replaced before its launch failed: ${error.message}`]);continue;}
|
|
1530
|
+
effects.push(...moved.effects);
|
|
1441
1531
|
report.failed.push([id,error.message]);
|
|
1442
1532
|
}
|
|
1443
1533
|
}
|
|
@@ -1474,15 +1564,15 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1474
1564
|
}
|
|
1475
1565
|
}
|
|
1476
1566
|
|
|
1477
|
-
function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
|
|
1567
|
+
async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
|
|
1478
1568
|
const machine = normalMachine(task.machine);
|
|
1479
1569
|
// Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
|
|
1480
1570
|
// no machine default decides it.
|
|
1481
1571
|
const window = workflowFor(board.root).params.session_window;
|
|
1482
1572
|
if (typeof window !== 'boolean') throw unavailable('the workflow declares no params.session_window (true or false): whether a board session opens a window on its machine is the workflow\'s choice');
|
|
1483
1573
|
{
|
|
1484
|
-
const made = resolveLocalWorkspace(board, task);
|
|
1485
|
-
|
|
1574
|
+
const made = await resolveLocalWorkspace(board, task);
|
|
1575
|
+
runTx(board, task.id, runId, (db) => {
|
|
1486
1576
|
db.prepare('UPDATE tasks SET workspace_path=?, branch_name=COALESCE(branch_name,?) WHERE id=?').run(made, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id);
|
|
1487
1577
|
});
|
|
1488
1578
|
task = { ...task, workspace_path: made };
|
|
@@ -1500,7 +1590,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1500
1590
|
// a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
|
|
1501
1591
|
// session starts (never two processes on one session)
|
|
1502
1592
|
// Every old pane in the slot must be settled, including a failed launch with no session id.
|
|
1503
|
-
closeRequested(board, { undelivered: [] }, { taskId: task.id });
|
|
1593
|
+
await closeRequested(board, { undelivered: [] }, { taskId: task.id });
|
|
1504
1594
|
const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
|
|
1505
1595
|
.map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
|
|
1506
1596
|
.find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
|
|
@@ -1510,7 +1600,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1510
1600
|
// the round's own fleet reading: a launch never reads the fleet again
|
|
1511
1601
|
const fresh = fleet;
|
|
1512
1602
|
if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
|
|
1513
|
-
const r = doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
1603
|
+
const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
1514
1604
|
const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1515
1605
|
if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1516
1606
|
const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
|
|
@@ -1519,7 +1609,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1519
1609
|
}
|
|
1520
1610
|
if (prev?.close_requested && !prev.closed_at) {
|
|
1521
1611
|
const report = { undelivered: [] };
|
|
1522
|
-
closeRequested(board, report, { taskId: task.id });
|
|
1612
|
+
await closeRequested(board, report, { taskId: task.id });
|
|
1523
1613
|
prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
1524
1614
|
// a pane that could not be closed still holds the session: no second process is started on it
|
|
1525
1615
|
if (prev?.close_requested && !prev.closed_at) throw unavailable(`the slot's session ${prev.session_id} asked to be closed and its pane ${prev.pane} could not be: ${report.undelivered.map((u) => u[2]).join('; ') || 'no reason given'}`);
|
|
@@ -1529,12 +1619,20 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1529
1619
|
// launch (Claude Code's) is bound with it, one that names itself (Codex) is bound from a later round's fleet reading.
|
|
1530
1620
|
// The pane id is chosen from the launch key and recorded before open runs, so a launch whose answer is lost (a
|
|
1531
1621
|
// timeout, a closed stream) is still found by its pane in a later round's fleet reading, and by its session id.
|
|
1532
|
-
const openOwned = (key, sid, mode, args) => {
|
|
1622
|
+
const openOwned = async (key, sid, mode, args) => {
|
|
1533
1623
|
const chosen = `p_${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
1534
|
-
tx(board, (db) =>
|
|
1535
|
-
|
|
1536
|
-
|
|
1624
|
+
tx(board, (db) => {
|
|
1625
|
+
if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its launch was prepared; nothing was started`), { launchSuperseded: true });
|
|
1626
|
+
setRunSession(db, runId, { ...meta, surface: 'pane', machine, harness, slot,
|
|
1627
|
+
session_id: sid, mode, launch_key: key, pane: chosen, launch_pending: true, launched_at: now() });
|
|
1628
|
+
});
|
|
1629
|
+
const result = await sc([...args.slice(0, args.indexOf('--key')), '--pane', chosen, ...args.slice(args.indexOf('--key'))], { timeout: 180_000, as: boardAddress(board.label) });
|
|
1537
1630
|
const pane = lastJson(result.stdout)?.pane ?? chosen;
|
|
1631
|
+
// the run ended while its pane opened: the pane it opened is closed, never left working for an ended run
|
|
1632
|
+
if (!view(board, (db) => runCurrent(db, task.id, runId))) {
|
|
1633
|
+
try { await stopSession(board, { surface: 'pane', machine, harness, pane, session_id: sid, launch_key: key }); } catch { /* the slot's next launch settles it */ }
|
|
1634
|
+
throw Object.assign(new Error(`run ${runId} of ${task.id} ended while its pane opened; pane ${pane} was closed`), { launchSuperseded: true });
|
|
1635
|
+
}
|
|
1538
1636
|
if (pane !== chosen) tx(board, (db) => {
|
|
1539
1637
|
const before = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode ?? {};
|
|
1540
1638
|
setRunSession(db, runId, { ...before, pane });
|
|
@@ -1546,6 +1644,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1546
1644
|
const reason = result.code === 0 ? `launch ${key} opened pane ${pane}; its ${harness} session is bound from the fleet reading` :
|
|
1547
1645
|
`launch ${key} (pane ${pane}) gave no answer; it is bound from the fleet reading if it opened: ${(result.stderr || result.stdout).trim().slice(-300)}`;
|
|
1548
1646
|
tx(board, (db) => {
|
|
1647
|
+
if (!runCurrent(db, task.id, runId)) return;
|
|
1549
1648
|
setRunSession(db, runId, { launch_pending: true, launch_error: reason });
|
|
1550
1649
|
event(db, task.id, 'session_launch_pending', { key, pane, reason }, runId);
|
|
1551
1650
|
});
|
|
@@ -1557,34 +1656,38 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1557
1656
|
// session it did not launch, left running at its run's end) is its owner's while it runs: it is never resumed beside
|
|
1558
1657
|
// itself, and the launch waits for the owner to end it.
|
|
1559
1658
|
if (prev?.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
|
|
1560
|
-
const reading = nativeReading(
|
|
1659
|
+
const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id), prev);
|
|
1561
1660
|
if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
|
|
1562
1661
|
fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
|
|
1563
1662
|
if (reading.alive && prev.released) throw unavailable(`released session ${addressOf(prev)} still runs; the launch waits for its owner to end it`);
|
|
1564
1663
|
if (reading.alive && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
|
|
1565
1664
|
}
|
|
1566
1665
|
if (prev?.forgotten) {
|
|
1567
|
-
if (!prev.close_confirmed_at && prev.pane && panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
|
|
1666
|
+
if (!prev.close_confirmed_at && prev.pane && await panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
|
|
1568
1667
|
replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
|
|
1569
1668
|
}
|
|
1570
1669
|
else if (prev && (prev.harness ?? 'claude-code') !== harness) {
|
|
1571
1670
|
// the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
|
|
1572
1671
|
// are its context), and the last session, if still open, is closed so two harnesses never work one card
|
|
1573
|
-
if (prev.pane && !prev.closed_at && !adoptedOnly(prev)) stopSession(board, prev); // an adopted session is released, never closed
|
|
1672
|
+
if (prev.pane && !prev.closed_at && !adoptedOnly(prev)) await stopSession(board, prev); // an adopted session is released, never closed
|
|
1574
1673
|
replaced = { session_id: prev.session_id, why: `the card's lane runs ${harness}, not ${prev.harness ?? 'claude-code'}`, letGo: true };
|
|
1575
1674
|
} else if (prev && prev.machine === machine) {
|
|
1576
1675
|
// a session the board closed, or one whose close its own command asked for (the next tick's sweep closes that
|
|
1577
1676
|
// run's pane), is not kept, whatever a fleet reading taken before the close still lists
|
|
1578
|
-
const paneLive = prev.pane ? panePresent(prev.machine, prev.pane) : false;
|
|
1677
|
+
const paneLive = prev.pane ? await panePresent(prev.machine, prev.pane) : false;
|
|
1579
1678
|
if (paneLive && (prev.closed_at || prev.close_requested || prev.forgotten)) throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
|
|
1580
1679
|
if (!prev.closed_at && !prev.close_requested && (prev.pane ? paneLive : sessionAlive(fleet, prev))) {
|
|
1581
1680
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
1582
1681
|
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, monitor_held: _mh, ...kept } = prev;
|
|
1583
1682
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
1584
|
-
// a kept session is told what this run is for (its card came back to it, with what happened since)
|
|
1585
|
-
|
|
1683
|
+
// a kept session is told what this run is for (its card came back to it, with what happened since), only while
|
|
1684
|
+
// this run is still the card's current one (its pane and native reads were awaited)
|
|
1685
|
+
tx(board, (db) => {
|
|
1686
|
+
if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its session was read; nothing was told to it`), { launchSuperseded: true });
|
|
1687
|
+
setRunSession(db, runId, { ...session, ...meta });
|
|
1688
|
+
});
|
|
1586
1689
|
// filed in its mailbox, which delivers it
|
|
1587
|
-
if (input) fileMail(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`);
|
|
1690
|
+
if (input) { assertRunCurrent(board, task.id, runId); await fileMail(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
|
|
1588
1691
|
} else if (prev.resumable !== false) {
|
|
1589
1692
|
const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
|
|
1590
1693
|
const released = view(board,db=>{
|
|
@@ -1601,7 +1704,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1601
1704
|
// a session is resumed in its own harness, whatever the lane's is now
|
|
1602
1705
|
const its = HARNESSES[prev.harness ?? 'claude-code'] ?? HARNESSES['claude-code'];
|
|
1603
1706
|
const launchKey = `board-${task.id}-r${runId}-resume`;
|
|
1604
|
-
session = openOwned(launchKey, prev.session_id, 'resume', ['open', '--new', its.program, ...base.slice(3),
|
|
1707
|
+
session = await openOwned(launchKey, prev.session_id, 'resume', ['open', '--new', its.program, ...base.slice(3),
|
|
1605
1708
|
'--key', launchKey, '--input', told, '--', ...its.resume(prev.session_id), ...tail]);
|
|
1606
1709
|
} else replaced = { session_id: prev.session_id, why: 'its resume never came up' };
|
|
1607
1710
|
} else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
|
|
@@ -1615,7 +1718,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1615
1718
|
if (!session) {
|
|
1616
1719
|
const sid = how.namesItself ? null : randomUUID();
|
|
1617
1720
|
const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
|
|
1618
|
-
session = openOwned(launchKey, sid, 'fresh', [...base, '--key', launchKey,
|
|
1721
|
+
session = await openOwned(launchKey, sid, 'fresh', [...base, '--key', launchKey,
|
|
1619
1722
|
'--input', input ?? `Card ${task.id}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...tail]);
|
|
1620
1723
|
if (replaced) session.replaces = replaced.session_id;
|
|
1621
1724
|
}
|
|
@@ -1649,24 +1752,25 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1649
1752
|
}
|
|
1650
1753
|
|
|
1651
1754
|
/** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
|
|
1652
|
-
function panePresent(machine, pane) {
|
|
1653
|
-
const r = doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
1755
|
+
async function panePresent(machine, pane) {
|
|
1756
|
+
const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
1654
1757
|
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1655
1758
|
if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1656
1759
|
return panes.some((p) => p.pane === pane && !p.dead);
|
|
1657
1760
|
}
|
|
1658
1761
|
|
|
1659
1762
|
/** Whether a pane just launched is still there after the harness had a moment to start. */
|
|
1660
|
-
function paneSurvives(machine, pane) {
|
|
1661
|
-
|
|
1662
|
-
|
|
1763
|
+
async function paneSurvives(machine, pane) {
|
|
1764
|
+
// a timer, never a blocking wait: the owner answers verbs while the harness starts
|
|
1765
|
+
await new Promise((resolve) => setTimeout(resolve, 6000));
|
|
1766
|
+
return await panePresent(machine, pane);
|
|
1663
1767
|
}
|
|
1664
1768
|
|
|
1665
1769
|
/** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
|
|
1666
|
-
function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {} } = {}) {
|
|
1667
|
-
const workspace = resolveLocalWorkspace(board, task);
|
|
1770
|
+
async function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {} } = {}) {
|
|
1771
|
+
const workspace = await resolveLocalWorkspace(board, task);
|
|
1668
1772
|
if(meta.agent_profile_intent)meta.agent_profile_intent.worker={...meta.agent_profile_intent.worker,harness:lane.harness,model:task.model_override??lane.model??null,cwd:workspace};
|
|
1669
|
-
|
|
1773
|
+
runTx(board, task.id, runId, (db) => db.prepare('UPDATE tasks SET workspace_path=?,branch_name=COALESCE(branch_name,?) WHERE id=?').run(workspace, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id));
|
|
1670
1774
|
// the card's own skills and the ones its status's run names (a reviewer's, for one)
|
|
1671
1775
|
const skills = [...new Set([...task.skills, ...runSkills])];
|
|
1672
1776
|
const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
|
|
@@ -1694,12 +1798,19 @@ function launchHeadless(board, task, runId, lane, settings, { prompt = null, ski
|
|
|
1694
1798
|
});
|
|
1695
1799
|
child.unref();
|
|
1696
1800
|
const session = { surface: 'headless', pid: child.pid, machine: localMachine(), mode: 'fresh', launched_at: now(), exit_file: exitFile, launch_secret: createHash('sha256').update(launchSecret).digest('hex'), ...meta };
|
|
1697
|
-
|
|
1698
|
-
|
|
1801
|
+
// recorded against this run only while it is still the card's current one; a run ended meanwhile gets no worker
|
|
1802
|
+
const recorded = tx(board, (db) => {
|
|
1803
|
+
if (!runCurrent(db, task.id, runId)) return false;
|
|
1804
|
+
db.prepare('UPDATE tasks SET worker_pid = ? WHERE id = ? AND current_run_id = ?').run(child.pid, task.id, runId);
|
|
1699
1805
|
db.prepare('UPDATE task_runs SET worker_pid = ? WHERE id = ?').run(child.pid, runId);
|
|
1700
1806
|
setRunSession(db, runId, session);
|
|
1701
1807
|
event(db, task.id, 'spawned', { pid: child.pid }, runId);
|
|
1808
|
+
return true;
|
|
1702
1809
|
});
|
|
1810
|
+
if (!recorded) {
|
|
1811
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { try { process.kill(child.pid, 'SIGTERM'); } catch { /* already gone */ } }
|
|
1812
|
+
throw Object.assign(new Error(`run ${runId} of ${task.id} ended while its worker started; the worker was stopped`), { launchSuperseded: true });
|
|
1813
|
+
}
|
|
1703
1814
|
return session;
|
|
1704
1815
|
}
|
|
1705
1816
|
|
|
@@ -1714,9 +1825,9 @@ function rotateLog(file, max, backups) {
|
|
|
1714
1825
|
}
|
|
1715
1826
|
|
|
1716
1827
|
/** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
|
|
1717
|
-
function resolveLocalWorkspace(board, task) {
|
|
1828
|
+
async function resolveLocalWorkspace(board, task) {
|
|
1718
1829
|
const root = board.root;
|
|
1719
|
-
if (task.workspace_kind !== 'scratch') return resolveWorkspace(task, true).path;
|
|
1830
|
+
if (task.workspace_kind !== 'scratch') return (await resolveWorkspace(task, true)).path;
|
|
1720
1831
|
const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
|
|
1721
1832
|
mkdirSync(dir, { recursive: true });
|
|
1722
1833
|
return dir;
|
|
@@ -1760,7 +1871,7 @@ const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_
|
|
|
1760
1871
|
* of two rounds that read one request, one binds it and the other leaves it; the bind and the failure are each written
|
|
1761
1872
|
* only while that claim is the card's latest.
|
|
1762
1873
|
*/
|
|
1763
|
-
export function completeAdoptions(board, fleet, settings, report, lanes = null) {
|
|
1874
|
+
export async function completeAdoptions(board, fleet, settings, report, lanes = null) {
|
|
1764
1875
|
const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at FROM task_events e
|
|
1765
1876
|
WHERE ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
|
|
1766
1877
|
AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ${ADOPTION_ANSWERS})`).all());
|
|
@@ -1776,7 +1887,7 @@ export function completeAdoptions(board, fleet, settings, report, lanes = null)
|
|
|
1776
1887
|
const asked = json(request.payload, {}) ?? {};
|
|
1777
1888
|
const progress = {};
|
|
1778
1889
|
try {
|
|
1779
|
-
const got = adoptSession(board, row.task_id, asked.session, { request: request.id, open: row.id, progress, workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null , lanes});
|
|
1890
|
+
const got = await adoptSession(board, row.task_id, asked.session, { request: request.id, open: row.id, progress, workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null , lanes});
|
|
1780
1891
|
(report.adopted ??= []).push([row.task_id, got.address]);
|
|
1781
1892
|
} catch (error) {
|
|
1782
1893
|
const message = String(error.message).slice(-400);
|
|
@@ -1807,11 +1918,11 @@ function adoptionOpenTo(db, id, request, open) {
|
|
|
1807
1918
|
}
|
|
1808
1919
|
|
|
1809
1920
|
/** A session in no pane (a native process its machine's harness discovery holds as running), by its full address. */
|
|
1810
|
-
function nativeListing(who) {
|
|
1921
|
+
async function nativeListing(who) {
|
|
1811
1922
|
const m = /^sc:([^:]+):([^:]+):(.+)$/.exec(String(who));
|
|
1812
1923
|
if (!m || !HARNESSES[m[2]]) return null;
|
|
1813
1924
|
const [, host, harness, session_id] = m;
|
|
1814
|
-
try { return nativeReading(
|
|
1925
|
+
try { return nativeReading(await readNative(host, harness, session_id), { session_id, harness }).alive === true ? { address: who, machine: normalMachine(host), harness, session_id, pane: null, name: null } : null; }
|
|
1815
1926
|
catch { return null; }
|
|
1816
1927
|
}
|
|
1817
1928
|
|
|
@@ -1831,27 +1942,27 @@ function adoptedRole(db, board, entry, task, run) {
|
|
|
1831
1942
|
return runSpec(entry, t?.target ? String(templateValue(t.target, scope)) : status, task)?.role ?? null;
|
|
1832
1943
|
}
|
|
1833
1944
|
|
|
1834
|
-
function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null, lanes = null } = {}) {
|
|
1835
|
-
fleet ??= observeFleet();
|
|
1945
|
+
async function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null, lanes = null } = {}) {
|
|
1946
|
+
fleet ??= await observeFleet();
|
|
1836
1947
|
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
1837
|
-
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? nativeListing(who);
|
|
1948
|
+
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? await nativeListing(who);
|
|
1838
1949
|
if (!found) throw Object.assign(new Error(`the fleet lists no live session ${who}`), { notListed: true });
|
|
1839
1950
|
const [, host, harness, sessionId] = found.address.split(':');
|
|
1840
1951
|
if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
|
|
1841
|
-
const native = found.pane ? null : nativeReading(
|
|
1842
|
-
if (found.pane ? !panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
|
|
1952
|
+
const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId), { session_id: sessionId, harness });
|
|
1953
|
+
if (found.pane ? !await panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
|
|
1843
1954
|
const beforeRun = view(board, (db) => currentRun(db, id));
|
|
1844
1955
|
const beforeSession = beforeRun?.metadata?.supercode;
|
|
1845
1956
|
if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
|
|
1846
|
-
if (beforeSession?.pane && beforeSession.pane !== found.pane && panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
|
|
1957
|
+
if (beforeSession?.pane && beforeSession.pane !== found.pane && await panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
|
|
1847
1958
|
|
|
1848
1959
|
// the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
|
|
1849
1960
|
let dir = workspace ?? native?.row?.cwd;
|
|
1850
1961
|
if (!dir) {
|
|
1851
|
-
dir =
|
|
1962
|
+
dir = (await readNative(host, harness, sessionId)).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
|
|
1852
1963
|
}
|
|
1853
1964
|
if (!dir && found.pane) {
|
|
1854
|
-
const r = runtimeDoor(normalMachine(host)===localMachine()?null:host,'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
1965
|
+
const r = await runtimeDoor(normalMachine(host)===localMachine()?null:host,'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
1855
1966
|
dir = (json(r.stdout.trim(), []) ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
|
|
1856
1967
|
}
|
|
1857
1968
|
// Rule 2: a session working a card role is that role's agent, however it started; adopted, it joins the agent as a
|
|
@@ -1875,11 +1986,13 @@ function adoptSession(board, id, who, { request = null, open = request, progress
|
|
|
1875
1986
|
});
|
|
1876
1987
|
progress.claimed = request !== null;
|
|
1877
1988
|
// The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
|
|
1878
|
-
const agent = lane ? localCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole, sponsor: card0?.sponsor ?? null }) : null;
|
|
1989
|
+
const agent = lane ? await localCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole, sponsor: card0?.sponsor ?? null }) : null;
|
|
1879
1990
|
// A session another agent already owns is refused before anything is recorded: the run would name one agent and the
|
|
1880
1991
|
// agent store another, and every round after would trip on it.
|
|
1881
1992
|
const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
|
|
1882
1993
|
if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
|
|
1994
|
+
// the card's previous session is read before the bind decides whether it is still live
|
|
1995
|
+
await readBefore(fleet, view(board, (db) => lastSession(db, id)));
|
|
1883
1996
|
const adopted = tx(board, (db) => {
|
|
1884
1997
|
const task = getTask(db, id);
|
|
1885
1998
|
if (!task) throw new Error(`no card ${id}`);
|
|
@@ -1929,7 +2042,7 @@ function adoptSession(board, id, who, { request = null, open = request, progress
|
|
|
1929
2042
|
}
|
|
1930
2043
|
|
|
1931
2044
|
/** A manager/creator's manual native resume rebinds its existing run; this opens no process. */
|
|
1932
|
-
function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
2045
|
+
async function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
1933
2046
|
if (!monitorsRuns(entry) || !fleet.ok) return;
|
|
1934
2047
|
const rows = view(board, db => db.prepare(`SELECT r.* FROM task_runs r JOIN tasks t ON t.id=r.task_id
|
|
1935
2048
|
WHERE t.current_run_id IS NULL AND t.status NOT IN ('done','archived') AND r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id)
|
|
@@ -1942,7 +2055,7 @@ function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
1942
2055
|
const live = fleet.sessions?.find(s => s.address === addressOf(saved) && s.state === 'running' && s.pane);
|
|
1943
2056
|
if (!live) continue;
|
|
1944
2057
|
if (!panes.has(saved.machine)) {
|
|
1945
|
-
const answer = doorOn(saved.machine,'harness.v1.panes.list',{}, {timeout:60000});
|
|
2058
|
+
const answer = await doorOn(saved.machine,'harness.v1.panes.list',{}, {timeout:60000});
|
|
1946
2059
|
const list = answer.code === 0 ? json(answer.stdout,null) : null;
|
|
1947
2060
|
panes.set(saved.machine,Array.isArray(list) ? new Set(list.filter(p=>!p.dead).map(p=>p.pane)) : null);
|
|
1948
2061
|
}
|
|
@@ -1977,22 +2090,25 @@ function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
1977
2090
|
|
|
1978
2091
|
|
|
1979
2092
|
/**
|
|
1980
|
-
* File one message from the board in its receiver's mailbox, and stop there (D138 step 6, D140):
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
1983
|
-
*
|
|
1984
|
-
*
|
|
1985
|
-
* is
|
|
2093
|
+
* File one message from the board in its receiver's mailbox, and stop there (D138 step 6, D140): through this machine's
|
|
2094
|
+
* daemon (`harness.v1.mail.file`, in this process over its held channel), which names the sender's identity itself and
|
|
2095
|
+
* writes it through the mailbox's filing door, idempotent by the message id `key` fixes. Nothing here waits on the
|
|
2096
|
+
* event loop. The mailbox delivers it: the machine daemon's watch loop hands it to its session by the session's own
|
|
2097
|
+
* door, and carries mail for another machine there through Teams. `wake: false` leaves it for the session's next turn.
|
|
2098
|
+
* A question the board waits on (`ask`) is answered by a reply to the board's address; a notice is not. Throws only
|
|
2099
|
+
* when the filing failed.
|
|
1986
2100
|
*/
|
|
1987
|
-
function fileMail(board, to, body, key, { ask = false, wake = true, subject } = {}) {
|
|
2101
|
+
async function fileMail(board, to, body, key, { ask = false, wake = true, subject } = {}) {
|
|
1988
2102
|
if (boardCopyOf(board)) throw copyRefusal(board, 'files no mail for sessions or managers');
|
|
1989
2103
|
const request = {
|
|
1990
2104
|
from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
|
|
1991
2105
|
body: ask ? `[A question from the ${board.label} board] ${body}` : `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
|
|
2106
|
+
// the board's own Teams context (the call it files in): the sender is observed in that team, never the daemon's
|
|
2107
|
+
context: current().facts.SUPERCODE_CONTEXT ?? null,
|
|
1992
2108
|
};
|
|
1993
|
-
const r =
|
|
1994
|
-
const answer = lastJson(r.stdout);
|
|
1995
|
-
if (answer?.code !== 0) throw new Error((answer?.text || r.stderr ||
|
|
2109
|
+
const r = await runtimeDoor(null, 'harness.v1.mail.file', request, { timeout: 30_000 });
|
|
2110
|
+
const answer = r.code === 0 ? lastJson(r.stdout) : null;
|
|
2111
|
+
if (answer?.code !== 0) throw new Error((answer?.text || (lastJson(r.stderr)?.message ?? r.stderr) || 'no answer from the mailbox\'s filing door').trim().slice(-300));
|
|
1996
2112
|
return { filed: true, message_id: answer.message_id, new: answer.new === true };
|
|
1997
2113
|
}
|
|
1998
2114
|
|
|
@@ -2006,7 +2122,7 @@ export async function alertManagers(root, slug, text, key) {
|
|
|
2006
2122
|
const label = boardLabel(root, slug);
|
|
2007
2123
|
let told = 0;
|
|
2008
2124
|
for (const to of managers) {
|
|
2009
|
-
try { fileMail({ root, label }, to, text, key, { subject: text.split('\n')[0] }); told += 1; } catch { /* a filing that failed here is said by the next alert */ }
|
|
2125
|
+
try { await fileMail({ root, label }, to, text, key, { subject: text.split('\n')[0] }); told += 1; } catch { /* a filing that failed here is said by the next alert */ }
|
|
2010
2126
|
}
|
|
2011
2127
|
return told;
|
|
2012
2128
|
}
|
|
@@ -2017,7 +2133,7 @@ export async function alertManagers(root, slug, text, key) {
|
|
|
2017
2133
|
* waits on, so a second filing of it files nothing new. A mail inside its batch window waits for the next pass; one
|
|
2018
2134
|
* whose filing failed here is reported and filed by the next pass.
|
|
2019
2135
|
*/
|
|
2020
|
-
export function fileBoardMail(board, report, taskId = null) {
|
|
2136
|
+
export async function fileBoardMail(board, report, taskId = null) {
|
|
2021
2137
|
// a message older than the workflow's notice_max_age_seconds is history, not news (a backlog replayed after an
|
|
2022
2138
|
// upgrade or a long stop): it is passed over, recorded once, never filed
|
|
2023
2139
|
const maxAge = Number(workflowFor(board.root)?.params?.notice_max_age_seconds) || null;
|
|
@@ -2034,7 +2150,7 @@ export function fileBoardMail(board, report, taskId = null) {
|
|
|
2034
2150
|
try {
|
|
2035
2151
|
const task = view(board, db => getTask(db, first.task_id));
|
|
2036
2152
|
const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
|
|
2037
|
-
const filed = fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
2153
|
+
const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
2038
2154
|
tx(board, db => {
|
|
2039
2155
|
for (const member of mail.members) if (!db.prepare("SELECT 1 FROM task_events WHERE kind='board_message_sent' AND task_id=? AND json_extract(payload,'$.event')=?").get(member.task_id, member.id)) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, message_id: filed.message_id, ...(mail.message_id ? { request_id: member.message_id ?? mail.message_id } : {}) });
|
|
2040
2156
|
});
|
|
@@ -2114,17 +2230,18 @@ function sessionRecord(db) {
|
|
|
2114
2230
|
* that resumes the session subscribes it again at its launch and each round while it is the card's current run.
|
|
2115
2231
|
* A creator's or manager's subscription is never ended here: its notices wait for the session to come back.
|
|
2116
2232
|
*/
|
|
2117
|
-
function endWorkerSubscriptions(board, fleet, report) {
|
|
2233
|
+
async function endWorkerSubscriptions(board, fleet, report) {
|
|
2118
2234
|
// each worker subscriber's liveness is read before the write
|
|
2119
2235
|
const aliveNow = new Map();
|
|
2120
|
-
if (fleet.ok) for (const { chat_id: address } of board.db.prepare("SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) aliveNow.set(address, subscriberAlive(fleet, address));
|
|
2236
|
+
if (fleet.ok) for (const { chat_id: address } of board.db.prepare("SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) aliveNow.set(address, await subscriberAlive(fleet, address));
|
|
2121
2237
|
tx(board, (db) => {
|
|
2122
2238
|
const { held, latest } = sessionRecord(db);
|
|
2123
2239
|
const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
|
|
2124
2240
|
const ended = new Map();
|
|
2125
2241
|
for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
|
|
2126
2242
|
const address = row.chat_id, record = latest.get(address);
|
|
2127
|
-
|
|
2243
|
+
// a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
|
|
2244
|
+
if (!record || record.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
|
|
2128
2245
|
const evidence = record.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at } : null;
|
|
2129
2246
|
if (!evidence || !remove.run(row.task_id, address, row.thread_id).changes) continue;
|
|
2130
2247
|
if (!ended.has(address)) ended.set(address, { record, evidence, cards: [] });
|
|
@@ -2143,8 +2260,8 @@ function endWorkerSubscriptions(board, fleet, report) {
|
|
|
2143
2260
|
* another. Each subscription's notices go in order, and its cursor moves past a notice once it is filed (or passed
|
|
2144
2261
|
* over); a filing that failed here leaves the cursor before it, and the next pass files it.
|
|
2145
2262
|
*/
|
|
2146
|
-
function notify(board, fleet, report, entry) {
|
|
2147
|
-
endWorkerSubscriptions(board, fleet, report);
|
|
2263
|
+
async function notify(board, fleet, report, entry) {
|
|
2264
|
+
await endWorkerSubscriptions(board, fleet, report);
|
|
2148
2265
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
|
|
2149
2266
|
if (stopping) break;
|
|
2150
2267
|
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
@@ -2152,7 +2269,12 @@ function notify(board, fleet, report, entry) {
|
|
|
2152
2269
|
// a session the fleet does not list this pass is told next pass: one reading is not a loss (a loaded box drops
|
|
2153
2270
|
// sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
|
|
2154
2271
|
// has its subscription moved when the new one starts
|
|
2155
|
-
if (role !== 'creator' && fleet.ok
|
|
2272
|
+
if (role !== 'creator' && fleet.ok) {
|
|
2273
|
+
const alive = await subscriberAlive(fleet, sub.chat_id);
|
|
2274
|
+
if (alive === false) continue;
|
|
2275
|
+
// unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
|
|
2276
|
+
if (alive === null) { report.undelivered.push([sub.task_id, sub.chat_id, 'its liveness could not be read; its notices wait until it can be']); continue; }
|
|
2277
|
+
}
|
|
2156
2278
|
const kinds = heardBy(entry, role);
|
|
2157
2279
|
if (!kinds.length) continue;
|
|
2158
2280
|
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
|
|
@@ -2194,7 +2316,7 @@ function notify(board, fleet, report, entry) {
|
|
|
2194
2316
|
if (text) {
|
|
2195
2317
|
const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
|
|
2196
2318
|
// filed under its fixed key: a second pass filing the same notice files nothing new
|
|
2197
|
-
const filed = fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
|
|
2319
|
+
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
|
|
2198
2320
|
if (filed.new) tx(board, (db) => event(db, sub.task_id, 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, message_id: filed.message_id, wake }));
|
|
2199
2321
|
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
2200
2322
|
}
|
|
@@ -2360,7 +2482,7 @@ function mailboxOf(address) {
|
|
|
2360
2482
|
* read. Any other message is mail it does not take: it is refused to its sender, in its own mailbox, and filed as read
|
|
2361
2483
|
* only once that refusal is filed.
|
|
2362
2484
|
*/
|
|
2363
|
-
function readMailbox(board, address, report) {
|
|
2485
|
+
async function readMailbox(board, address, report) {
|
|
2364
2486
|
const box = mailboxOf(address);
|
|
2365
2487
|
if (!box || !existsSync(join(box, 'new'))) return;
|
|
2366
2488
|
for (const name of readdirSync(join(box, 'new')).filter((n) => n.endsWith('.json')).sort()) {
|
|
@@ -2370,7 +2492,7 @@ function readMailbox(board, address, report) {
|
|
|
2370
2492
|
const notice = envelope.kind === 'notice' || envelope.reply_via?.mode === 'none';
|
|
2371
2493
|
if (!envelope.in_reply_to && !notice) {
|
|
2372
2494
|
try {
|
|
2373
|
-
fileMail(board, envelope.from, `${address} takes answers to its questions and notices only; this message was not taken.`, `board-refused-${envelope.id}`);
|
|
2495
|
+
await fileMail(board, envelope.from, `${address} takes answers to its questions and notices only; this message was not taken.`, `board-refused-${envelope.id}`);
|
|
2374
2496
|
} catch (error) { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); continue; }
|
|
2375
2497
|
report.mailed.push([envelope.from, 'refused']);
|
|
2376
2498
|
}
|
|
@@ -2402,10 +2524,10 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
2402
2524
|
fleetRoot = root;
|
|
2403
2525
|
const loaded = await loadWorkflow(root);
|
|
2404
2526
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
2405
|
-
const fleet = Object.values(lanes).some((l) => l.surface === 'pane') ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
2527
|
+
const fleet = Object.values(lanes).some((l) => l.surface === 'pane') ? await observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
2406
2528
|
const out = [];
|
|
2407
2529
|
// the home's caps count every board, whichever boards are planned
|
|
2408
|
-
const held = homeHoldings(root, listBoards(root), lanes, fleet, entry);
|
|
2530
|
+
const held = await homeHoldings(root, listBoards(root), lanes, fleet, entry);
|
|
2409
2531
|
const caps = capacity(entry.workflow.dispatch?.capacity, held, evaluate);
|
|
2410
2532
|
// what each cap counts now, before anything this plan would start
|
|
2411
2533
|
Object.defineProperty(out, 'capacity', { value: caps.view().map((cap) => ({ ...cap, groups: cap.groups.map((g) => ({ ...g, max: capMax(cap, g, entry, lanes) })) })) });
|
|
@@ -2415,7 +2537,7 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
2415
2537
|
try {
|
|
2416
2538
|
board.label = boardLabel(root, slug);
|
|
2417
2539
|
await activatePullRequestReviews(board, entry, report, [], { dry: true, fleet });
|
|
2418
|
-
startCards(board, fleet, lanes, settings, report, entry, [], { dry: true, held });
|
|
2540
|
+
await startCards(board, fleet, lanes, settings, report, entry, [], { dry: true, held });
|
|
2419
2541
|
} finally { board.close(); }
|
|
2420
2542
|
out.push(report);
|
|
2421
2543
|
}
|
|
@@ -2438,12 +2560,12 @@ function noCodeReviewDue(board, id) {
|
|
|
2438
2560
|
/** The arc's session still in a turn: its review, and the close of that session the request brings (end_run), wait for
|
|
2439
2561
|
* the turn to end, so work in flight is never cut (t_f34710bc's coder had a proof take running when its review was
|
|
2440
2562
|
* requested at 15:01:13Z on 2026-10-04, and the close ended it). A session the reading cannot place waits on nothing. */
|
|
2441
|
-
function turnInFlight(board, id, fleet) {
|
|
2563
|
+
async function turnInFlight(board, id, fleet) {
|
|
2442
2564
|
if (!fleet?.ok) return null;
|
|
2443
2565
|
const session = currentRun(board.db, id)?.metadata?.supercode;
|
|
2444
2566
|
if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
|
|
2445
2567
|
let turn = fleet.sessions?.find((x) => x.address === addressOf(session))?.turn ?? null;
|
|
2446
|
-
if (turn == null && session.surface === 'native') try { turn = nativeReading(
|
|
2568
|
+
if (turn == null && session.surface === 'native') try { turn = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id), session).row?.activity?.turn ?? null; } catch { /* unread: waits on nothing */ }
|
|
2447
2569
|
// a turn stopped on a permission or a question (needs_input) is a turn not finished, as one still working is
|
|
2448
2570
|
return ['working', 'needs_input'].includes(turn) ? `its session ${addressOf(session)} is in a turn (${turn}); the review is requested once the turn ends` : null;
|
|
2449
2571
|
}
|
|
@@ -2466,26 +2588,26 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
2466
2588
|
const ready = view(board, eligible);
|
|
2467
2589
|
if (!ready) continue;
|
|
2468
2590
|
if (ready !== true) { report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
|
|
2469
|
-
const inTurn = turnInFlight(board, id, fleet);
|
|
2591
|
+
const inTurn = await turnInFlight(board, id, fleet);
|
|
2470
2592
|
if (inTurn) { report.queued.push([id, `review waits: ${inTurn}`]); continue; }
|
|
2471
2593
|
let payload = null;
|
|
2472
2594
|
try {
|
|
2473
|
-
payload = openReviewPullRequests(board, id);
|
|
2595
|
+
payload = await openReviewPullRequests(board, id);
|
|
2474
2596
|
// The arc's open PRs ask for one review only while some head has no verdict: after changes are
|
|
2475
2597
|
// requested the next run is the implementer's, until a new head is pushed.
|
|
2476
2598
|
// An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
|
|
2477
|
-
if (!payload && noCodeReviewDue(board, id)) payload = noCodeChange(board, id);
|
|
2599
|
+
if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
|
|
2478
2600
|
if (!payload) { report.queued.push([id, 'review waits: no open PR on its branch, a wt/<card> branch or a registered checkout']); continue; }
|
|
2479
2601
|
if (payload.waits) { report.queued.push([id, `review waits: ${payload.waits}`]); payload = null; continue; }
|
|
2480
|
-
if (!payload.no_code && !reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
2602
|
+
if (!payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
2481
2603
|
report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
|
|
2482
2604
|
continue;
|
|
2483
2605
|
}
|
|
2484
|
-
const evidence = payload.no_code ? { required: false, ready: true, reason: null, phase: 'source', no_code: payload.no_code } : inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
2606
|
+
const evidence = payload.no_code ? { required: false, ready: true, reason: null, phase: 'source', no_code: payload.no_code } : await inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
2485
2607
|
if (!evidence.ready) throw new Error(evidence.reason);
|
|
2486
2608
|
// a dry run names the review a tick would request and writes nothing
|
|
2487
2609
|
if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
|
|
2488
|
-
refreshCardInputs(board, id, { commits: true });
|
|
2610
|
+
await refreshCardInputs(board, id, { commits: true });
|
|
2489
2611
|
const moved = tx(board, db => {
|
|
2490
2612
|
const task = getTask(db, id);
|
|
2491
2613
|
if (eligible(db) !== true) return null;
|
|
@@ -2537,9 +2659,10 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
|
|
|
2537
2659
|
|
|
2538
2660
|
/**
|
|
2539
2661
|
* The delivery pass over every board of the home at `root`: what rounds recorded for after them (each due effect, with
|
|
2540
|
-
* its bounded attempts), board mail, subscription notices, and the board's agent records joined to Teams.
|
|
2541
|
-
* its own
|
|
2542
|
-
*
|
|
2662
|
+
* its bounded attempts), board mail, subscription notices, and the board's agent records joined to Teams. The owner
|
|
2663
|
+
* runs it in its own (the owner's) process after a round (startDelivery, one at a time, never awaited by the round), and an operator
|
|
2664
|
+
* runs it as `workflow deliver`. Every door, filing and Teams answer it waits on is awaited, never a synchronous wait,
|
|
2665
|
+
* so the owner answers verbs while it runs. Answers one report per board.
|
|
2543
2666
|
*/
|
|
2544
2667
|
/**
|
|
2545
2668
|
* Whether the board dispatches: board state, kept in the board's own store (`supercode_board_state`, key `dispatching`)
|
|
@@ -2562,7 +2685,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
2562
2685
|
// already under way when a board is switched off leaves it); `workflow deliver`, an operator's act, delivers as asked
|
|
2563
2686
|
const boards = (slugs ?? listBoards(root)).filter((slug) => !controller || dispatchingOf(root, slug).on);
|
|
2564
2687
|
if (!boards.length) return [];
|
|
2565
|
-
const fleet = observeFleet();
|
|
2688
|
+
const fleet = await observeFleet();
|
|
2566
2689
|
const out = [];
|
|
2567
2690
|
for (const slug of boards) {
|
|
2568
2691
|
if (controller && !dispatchingOf(root, slug).on) continue;
|
|
@@ -2578,10 +2701,10 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
2578
2701
|
// a controller's pass reads the board's switch before each phase and each effect: switched off meanwhile, what had
|
|
2579
2702
|
// not begun is left (an operator's `workflow deliver` delivers as asked)
|
|
2580
2703
|
const on = () => !controller || dispatchingOf(root, slug).on;
|
|
2581
|
-
if (on()) { fileBoardMail(board, report); phase('mail'); }
|
|
2704
|
+
if (on()) { await fileBoardMail(board, report); phase('mail'); }
|
|
2582
2705
|
if (on()) { report.effects = await runEffects(board, [], { pending: true, eligible: controller ? on : null }); phase('effects'); }
|
|
2583
|
-
if (on()) { notify(board, fleet, report, entry); phase('notify'); }
|
|
2584
|
-
if (on()) { synchronizeAgents(board, report); phase('agents'); }
|
|
2706
|
+
if (on()) { await notify(board, fleet, report, entry); phase('notify'); }
|
|
2707
|
+
if (on()) { await synchronizeAgents(board, report); phase('agents'); }
|
|
2585
2708
|
report.effects_failed = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NOT NULL').get().n);
|
|
2586
2709
|
report.effects_waiting = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NULL').get().n);
|
|
2587
2710
|
} catch (error) { report.error = error.message; }
|
|
@@ -2613,17 +2736,18 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2613
2736
|
fleetRoot = root;
|
|
2614
2737
|
discoveries.clear();
|
|
2615
2738
|
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
2616
|
-
const deployment = deploymentReceipt ? workflowRevision(root) : null;
|
|
2739
|
+
const deployment = deploymentReceipt ? await workflowRevision(root) : null;
|
|
2617
2740
|
const loaded = await loadWorkflow(root);
|
|
2618
2741
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
2619
2742
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
2620
2743
|
const fleetAt = Date.now();
|
|
2621
|
-
const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
2744
|
+
const fleet = needFleet ? await observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
2622
2745
|
const fleetMs = Date.now() - fleetAt;
|
|
2746
|
+
await readMemoryPressure();
|
|
2623
2747
|
const boards = slugs ?? listBoards(root);
|
|
2624
2748
|
// what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so): every
|
|
2625
2749
|
// board, including one whose dispatching is off, whose live workers still hold their places
|
|
2626
|
-
const held = dispatch ? homeHoldings(root, listBoards(root), lanes, fleet, entry) : [];
|
|
2750
|
+
const held = dispatch ? await homeHoldings(root, listBoards(root), lanes, fleet, entry) : [];
|
|
2627
2751
|
const out = [];
|
|
2628
2752
|
for (const slug of boards) {
|
|
2629
2753
|
if (stopping) break;
|
|
@@ -2646,17 +2770,17 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2646
2770
|
let mark = Date.now();
|
|
2647
2771
|
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
2648
2772
|
|
|
2649
|
-
readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
2773
|
+
await readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
2650
2774
|
await refreshRelationAnswers(board); phase('answers');
|
|
2651
2775
|
const effects = [];
|
|
2652
|
-
completeAdoptions(board, fleet, settings, report, lanes); reconnectContinuedSessions(board, fleet, entry, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
|
|
2776
|
+
await completeAdoptions(board, fleet, settings, report, lanes); await reconnectContinuedSessions(board, fleet, entry, report); await reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); await supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); await closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
|
|
2653
2777
|
report.unsettled = [];
|
|
2654
2778
|
report.promoted = recomputeReady(board, effects, report.unsettled);
|
|
2655
2779
|
tx(board,db=>auditEndedRuns(db,board,entry));
|
|
2656
2780
|
if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only, fleet });
|
|
2657
|
-
if (dispatch && !stopping) startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
|
|
2781
|
+
if (dispatch && !stopping) await startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
|
|
2658
2782
|
phase('start');
|
|
2659
|
-
if (dispatch && !only && !report.paused) observeIdle(board, fleet, settings, report, entry, effects);
|
|
2783
|
+
if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
|
|
2660
2784
|
// what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
|
|
2661
2785
|
queueEffects(board, effects);
|
|
2662
2786
|
phase('goals');
|
|
@@ -2676,7 +2800,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2676
2800
|
out.push(report);
|
|
2677
2801
|
}
|
|
2678
2802
|
if (deployment && out.every(report => !report.error)) {
|
|
2679
|
-
const receipt = retainWorkflowTick(root, deployment);
|
|
2803
|
+
const receipt = await retainWorkflowTick(root, deployment);
|
|
2680
2804
|
if (receipt && out[0]) out[0].deployment = receipt;
|
|
2681
2805
|
}
|
|
2682
2806
|
return out;
|