@volter/supercode-orchestrator 0.5.207 → 0.5.208
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/agents.mjs +27 -12
- package/bin/connection-runner.mjs +12 -21
- package/bin/orchestrator.mjs +16 -3
- package/board/agent-declarations.mjs +3 -3
- package/board/args.mjs +2 -2
- package/board/card-state.mjs +29 -0
- package/board/ci.mjs +87 -29
- package/board/cli.mjs +125 -91
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +354 -84
- package/board/engine.mjs +77 -50
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +3 -1
- package/board/listing.mjs +13 -11
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +2 -2
- package/board/published.mjs +119 -32
- package/board/readable-id.mjs +34 -0
- package/board/record-view.mjs +10 -1
- package/board/request-mail.mjs +9 -3
- package/board/store.mjs +95 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +58 -16
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +111 -44
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +95 -26
- package/index.d.ts +6 -2
- package/loop.mjs +51 -3
- package/mcp/bridge.mjs +4 -1
- package/mcp/hermes-kanban-tools.json +5 -1
- package/ontology.d.ts +7 -24
- package/orchestration.mjs +6 -3
- package/package.json +5 -4
- package/worker-route.mjs +31 -3
package/board/dispatch.mjs
CHANGED
|
@@ -33,12 +33,16 @@ let fleetRoot = null;
|
|
|
33
33
|
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
34
34
|
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
35
35
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
36
|
-
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
36
|
+
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
37
37
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
38
38
|
import { materializeAgentHome } from '../agent-home.mjs';
|
|
39
39
|
import { memorySnapshot } from '../hermes-cli.mjs';
|
|
40
|
-
import { hermesModel } from '../worker-route.mjs';
|
|
41
|
-
import {
|
|
40
|
+
import { hermesModel, codexPaneRoute } from '../worker-route.mjs';
|
|
41
|
+
import { refreshVault } from '../orchestration.mjs';
|
|
42
|
+
import { projectCodexPaneResume } from '../codex-pane-resume.mjs';
|
|
43
|
+
import { codexPaneInputs } from './codex-pane-inputs.mjs';
|
|
44
|
+
import { declareWorkerHome } from '../activation.mjs';
|
|
45
|
+
import { MODEL_ARGUMENTS, NAME_ARGUMENTS } from '../activation.mjs';
|
|
42
46
|
import { createRequire } from 'node:module';
|
|
43
47
|
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
44
48
|
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
@@ -46,7 +50,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
|
46
50
|
import { basename, dirname, join } from 'node:path';
|
|
47
51
|
import { createHash, randomUUID } from 'node:crypto';
|
|
48
52
|
import { fileURLToPath } from 'node:url';
|
|
49
|
-
import { view, boardCopyOf, boardPath, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
53
|
+
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
50
54
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
51
55
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
52
56
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
@@ -153,7 +157,7 @@ export function callerSession() {
|
|
|
153
157
|
export async function observeFleet(root = fleetRoot) {
|
|
154
158
|
const machines = await homeFleetMachines(root, normalMachine);
|
|
155
159
|
const fleet = await readMachineFleet(machines, localMachine());
|
|
156
|
-
presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), unknown: fleet.unknown ?? new Set() };
|
|
160
|
+
presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), offline: fleet.offline ?? new Set(), unknown: fleet.unknown ?? new Set(), errors: fleet.errors ?? new Map() };
|
|
157
161
|
notePresence(presenceReading);
|
|
158
162
|
return fleet;
|
|
159
163
|
}
|
|
@@ -199,10 +203,11 @@ async function agentMain(address) {
|
|
|
199
203
|
if (held && Date.now() - held.at <= READING_MS) return held.main;
|
|
200
204
|
const { machine, name } = agentAt(address);
|
|
201
205
|
const local = machine === localMachine();
|
|
202
|
-
const r = await doorOn(machine, 'harness.v1.agent.show', { name, ...(local ? { claim: { pid: process.pid, ppid: process.ppid, session: null, named: null, owner: null } } : {}) }, { timeout: 5_000 });
|
|
206
|
+
const r = await doorOn(machine, 'harness.v1.agent.show', { name, records_only: true, ...(local ? { claim: { pid: process.pid, ppid: process.ppid, session: null, named: null, owner: null } } : {}) }, { timeout: 5_000 });
|
|
203
207
|
const answer = r.code === 0 ? lastJson(r.stdout) : null;
|
|
204
|
-
const
|
|
205
|
-
const read = { at: Date.now(), main: typeof main === 'string' && main ? main : answer && Number.isInteger(answer.code) && answer.code !== 0 ? false : null
|
|
208
|
+
const agent = answer?.agents?.[0], main = agent?.main_session;
|
|
209
|
+
const read = { at: Date.now(), main: typeof main === 'string' && main ? main : answer && Number.isInteger(answer.code) && answer.code !== 0 ? false : null,
|
|
210
|
+
sessions: [main, ...(agent?.former_mains ?? [])].filter(value => typeof value === 'string' && value) };
|
|
206
211
|
agentMains.set(address, read);
|
|
207
212
|
return read.main;
|
|
208
213
|
}
|
|
@@ -211,6 +216,8 @@ async function agentMain(address) {
|
|
|
211
216
|
* An agent's address is as live as its main session.
|
|
212
217
|
*/
|
|
213
218
|
async function subscriberAlive(fleet, address) {
|
|
219
|
+
// The mail router's current inventory includes native sessions outside the pane/discovery reading.
|
|
220
|
+
if (fleet.noticeAlive?.has(address)) return true;
|
|
214
221
|
if (fleet.alive.has(address)) return true;
|
|
215
222
|
if (agentAt(address)) {
|
|
216
223
|
const main = await agentMain(address);
|
|
@@ -230,7 +237,7 @@ async function subscriberAlive(fleet, address) {
|
|
|
230
237
|
* daemon's index dropped a parent session that ran a subagent, so its absence there is never read as gone.
|
|
231
238
|
*/
|
|
232
239
|
function subscriberGone(fleet, address) {
|
|
233
|
-
if (fleet.alive.has(address) || fleet.native?.has(address)) return false;
|
|
240
|
+
if (fleet.noticeAlive?.has(address) || fleet.alive.has(address) || fleet.native?.has(address)) return false;
|
|
234
241
|
const session = sessionAt(address);
|
|
235
242
|
const seen = session && discoveries.get(nativeKey(session.machine, session.harness, session.session_id));
|
|
236
243
|
if (!seen?.complete) return false;
|
|
@@ -275,7 +282,12 @@ const txRead = (board, fn) => tx(board, fn);
|
|
|
275
282
|
/** Take the readings a decision or a board write about `session` will need, before it. */
|
|
276
283
|
async function readBefore(fleet, session) {
|
|
277
284
|
if (!session?.machine || session.surface === 'headless') return;
|
|
278
|
-
|
|
285
|
+
// A pane may use a different Codex home than its daemon. Its native activity
|
|
286
|
+
// locator names the source to discover; the daemon's default can miss it.
|
|
287
|
+
const locator = fleet?.sessions?.find(row => row.address === addressOf(session))?.locator;
|
|
288
|
+
const codexHome = session.codex_home ?? (session.harness === 'codex' && locator?.storage?.kind === 'file'
|
|
289
|
+
? /^(.*)[\\/]sessions[\\/]/.exec(locator.storage.path)?.[1] : null);
|
|
290
|
+
if (session.session_id) try { await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, codexHome); } catch { /* unread: unknown */ }
|
|
279
291
|
if (session.pane && !fleet?.native?.has(addressOf(session))) await readPaneList(session.machine);
|
|
280
292
|
}
|
|
281
293
|
/** A session's native discovery as this round read it (readNative); throws (unavailable) when it was not read. */
|
|
@@ -285,26 +297,29 @@ function nativeSessions(machine, harness, sessionId) {
|
|
|
285
297
|
throw unavailable('native liveness is read before it is used (readBefore), and was not');
|
|
286
298
|
}
|
|
287
299
|
/** Read a session's native discovery through its machine's door, once a round. */
|
|
288
|
-
async function readNative(machine, harness, sessionId) {
|
|
300
|
+
async function readNative(machine, harness, sessionId, codexHome = null) {
|
|
289
301
|
const key = nativeKey(machine, harness, sessionId), seen = discoveries.get(key);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
302
|
+
const home = harness === 'codex' ? codexHome ?? seen?.codex_home ?? null : null;
|
|
303
|
+
if (seen && (seen.codex_home ?? null) === home) return seen.sessions;
|
|
304
|
+
const { sessions, complete } = await discoverNative(machine, harness, sessionId, home);
|
|
305
|
+
discoveries.set(key, { at: Date.now(), sessions, complete, codex_home: home });
|
|
293
306
|
return sessions;
|
|
294
307
|
}
|
|
295
308
|
// A native discovery is bounded as the fleet reading is: one that does not answer in time is unknown, read next round.
|
|
296
309
|
const NATIVE_DISCOVERY_MS = 15_000;
|
|
297
310
|
const nativeKey = (machine, harness, sessionId) => `${normalMachine(machine)}\u0000${harness}\u0000${sessionId}`;
|
|
298
|
-
async function discoverNative(machine, harness, sessionId) {
|
|
311
|
+
async function discoverNative(machine, harness, sessionId, codexHome = null) {
|
|
299
312
|
if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
|
|
300
313
|
// the machine's own harness door, this one's or another's, as every other harness read here is
|
|
301
314
|
// Liveness uses the native activity receipt, never transcript previews. A name miss can match many retained
|
|
302
315
|
// rows; opening every one for its messages delays the board's liveness decision for data it never reads.
|
|
303
|
-
const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000, include_latest_candidates: false }, { timeout: NATIVE_DISCOVERY_MS });
|
|
316
|
+
const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000, include_latest_candidates: false, ...(harness === 'codex' && codexHome ? { homes: { codex: join(codexHome, 'sessions') } } : {}) }, { timeout: NATIVE_DISCOVERY_MS });
|
|
304
317
|
const page = result.code === 0 ? lastJson(result.stdout) : null;
|
|
305
318
|
if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
306
|
-
// whole: every source read, nothing truncated, from an index that keeps every
|
|
307
|
-
|
|
319
|
+
// whole: every source read and none still being reconciled, nothing truncated, from an index that keeps every
|
|
320
|
+
// session's own row (subscriberGone); a source still being read answers from the rows it held before
|
|
321
|
+
const complete = page.subagent_rows === 'own' && !(Array.isArray(page.failed) && page.failed.length) && !page.reconciling?.length
|
|
322
|
+
&& !page.receipt?.truncated && page.next_cursor == null;
|
|
308
323
|
return { sessions: page.sessions, complete };
|
|
309
324
|
}
|
|
310
325
|
/**
|
|
@@ -317,7 +332,7 @@ async function nativeAbsent(session) {
|
|
|
317
332
|
if (!session?.session_id || !session.machine) return false;
|
|
318
333
|
const harness = session.harness ?? 'claude-code';
|
|
319
334
|
try {
|
|
320
|
-
const rows = await readNative(session.machine, harness, session.session_id);
|
|
335
|
+
const rows = await readNative(session.machine, harness, session.session_id, session.codex_home ?? null);
|
|
321
336
|
return discoveries.get(nativeKey(session.machine, harness, session.session_id))?.complete === true
|
|
322
337
|
&& !rows.some((r) => r.locator?.session_id === session.session_id);
|
|
323
338
|
} catch { return false; }
|
|
@@ -399,17 +414,16 @@ function runLane(db, board, entry, task, lanes) {
|
|
|
399
414
|
return lanes[profile] ?? {};
|
|
400
415
|
}
|
|
401
416
|
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
// close and launch on this one. Its door answers `machine_offline`, which every caller tries again next round.
|
|
417
|
+
// Only a current explicit unlink establishes offline. A missing or unread record is unknown and cannot suppress
|
|
418
|
+
// a bounded direct door call: the record stream and that machine's channel are independent paths.
|
|
405
419
|
let presenceReading = null;
|
|
406
420
|
const offlineMachine = (machine) => {
|
|
407
421
|
const m = normalMachine(machine ?? localMachine());
|
|
408
|
-
return m !== localMachine() && presenceReading?.known === true &&
|
|
422
|
+
return m !== localMachine() && presenceReading?.known === true && presenceReading.offline.has(m);
|
|
409
423
|
};
|
|
410
424
|
/** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
|
|
411
425
|
const doorOn = async (machine, method, params, options) => offlineMachine(machine)
|
|
412
|
-
? { code: 1, stdout: '', stderr: JSON.stringify({ code: 'machine_offline', message: `machine ${normalMachine(machine)}
|
|
426
|
+
? { code: 1, stdout: '', stderr: JSON.stringify({ code: 'machine_offline', message: `machine ${normalMachine(machine)} is unlinked in the current fleet record: ${presenceReading.errors.get(normalMachine(machine)) ?? 'unlinked from the team'}` }) }
|
|
413
427
|
: await runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, options);
|
|
414
428
|
const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
|
|
415
429
|
|
|
@@ -517,7 +531,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
517
531
|
let locator;
|
|
518
532
|
try { locator = nativeReading(nativeSessions(session.machine, session.harness, session.session_id), session).row?.locator; } catch { continue; }
|
|
519
533
|
if (!locator) continue;
|
|
520
|
-
const answer = await doorOn(session.machine, 'harness.v1.sessions.load', { locator, options: { include_subagents: false, message_limit: 0, inline_media: 'metadata' } }, { timeout: NATIVE_DISCOVERY_MS });
|
|
534
|
+
const answer = await doorOn(session.machine, 'harness.v1.sessions.load', { locator, view: { first_response_only: true }, options: { include_subagents: false, message_limit: 0, inline_media: 'metadata' } }, { timeout: NATIVE_DISCOVERY_MS });
|
|
521
535
|
const loaded = answer.code === 0 ? lastJson(answer.stdout) : null;
|
|
522
536
|
const response = loaded?.session?.first_response;
|
|
523
537
|
if (loaded?.session?.session_id !== session.session_id || !['response', 'error'].includes(response?.outcome)
|
|
@@ -786,7 +800,7 @@ async function stopOwnedSession(board, session) {
|
|
|
786
800
|
if (r.code !== 0) throw new Error(`${doorTransient(r) ? 'close deferred: ' : ''}closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
787
801
|
// the close's receipt is a discovery taken after it, never the round's cached one from before
|
|
788
802
|
discoveries.delete(nativeKey(session.machine, session.harness, session.session_id));
|
|
789
|
-
const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id), session);
|
|
803
|
+
const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id, session.codex_home ?? null), session);
|
|
790
804
|
if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
|
|
791
805
|
return;
|
|
792
806
|
}
|
|
@@ -1070,8 +1084,8 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1070
1084
|
const scope = view(board, (db) => scopeOf(db, board, task, { params: entry.params }));
|
|
1071
1085
|
const where = whereOf(board, task);
|
|
1072
1086
|
// a subtask's run also sees its arc: the arc's text, tasks and thread (where its owner's rulings are)
|
|
1073
|
-
const arcContext = task.subtask_of ? view(board, (db) => cardContext(db, task.subtask_of)) : '';
|
|
1074
|
-
let prompt = render(spec.prompt, { ...scope, where, context: view(board, (db) => cardContext(db, id)), arc_context: arcContext, doors: doors(entry.workflow, status, spec.role, id, where, surface) });
|
|
1087
|
+
const arcContext = task.subtask_of ? view(board, (db) => cardContext(db, task.subtask_of, { nativeHolds: Boolean(entry.workflow.flags?.paused) })) : '';
|
|
1088
|
+
let prompt = render(spec.prompt, { ...scope, where, context: view(board, (db) => cardContext(db, id, { nativeHolds: Boolean(entry.workflow.flags?.paused) })), arc_context: arcContext, doors: doors(entry.workflow, status, spec.role, id, where, surface) });
|
|
1075
1089
|
// the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
|
|
1076
1090
|
// is a template, so a step can ask for isolation by naming a new one
|
|
1077
1091
|
const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
|
|
@@ -1113,7 +1127,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1113
1127
|
// and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
|
|
1114
1128
|
const context=current().facts.SUPERCODE_CONTEXT,team=agentScope(context);
|
|
1115
1129
|
const agent=await localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
|
|
1116
|
-
profile:lane.name,card:id,role:spec.role??slot
|
|
1130
|
+
profile:lane.name,card:id,handle:cardHandleOf(board,id),readableId:readableIdOf(board,id),role:spec.role??slot});
|
|
1117
1131
|
assertRunCurrent(board,id,runId);
|
|
1118
1132
|
recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
|
|
1119
1133
|
// G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
|
|
@@ -1132,7 +1146,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1132
1146
|
if(surface==='headless')meta.agent_env.HERMES_HOME=agentHome;
|
|
1133
1147
|
else{const memory=await memorySnapshot(agentHome).catch(()=>'');assertRunCurrent(board,id,runId);if(memory)prompt=`${prompt}\n\n${memory}`;}
|
|
1134
1148
|
const result = surface === 'pane'
|
|
1135
|
-
? await launchPane(board, task, runId, lane, fleet, { input: briefByReference(board, task, runId, spec.role ?? slot, where, prompt), slot, meta })
|
|
1149
|
+
? await launchPane(board, task, runId, lane, fleet, { input: briefByReference(board, task, runId, spec.role ?? slot, where, prompt), slot, meta, orchestration: entry.orchestration ?? { profiles: entry.profiles } })
|
|
1136
1150
|
: await launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
|
|
1137
1151
|
tx(board,db=>{
|
|
1138
1152
|
if(!runCurrent(db,id,runId))return; // replaced once launched: its session is the ended run's, closed as one
|
|
@@ -1179,7 +1193,8 @@ export function briefByReference(board, task, runId, role, where, prompt) {
|
|
|
1179
1193
|
const local = !task.machine || normalMachine(task.machine) === localMachine();
|
|
1180
1194
|
const from = local ? `in the file ${file}: read the whole file now`
|
|
1181
1195
|
: `kept by the board: print it with \`supercode workflow brief ${task.id} ${where}\` (into a file outside your repository when it is long) and read all of it now`;
|
|
1182
|
-
|
|
1196
|
+
const ref = cardHandleOf(board, task.id)?.toUpperCase();
|
|
1197
|
+
return `The ${board.label} board started run ${runId} of card ${ref ? `${ref} (${task.id})` : task.id} and you are its ${role}. Your instructions from the board are the brief ${from}, then carry it out as your own task without waiting for another message.`;
|
|
1183
1198
|
}
|
|
1184
1199
|
|
|
1185
1200
|
/** Start one card now by a person's word (a triage card's session, a manager's start): the workflow's start event. */
|
|
@@ -1499,7 +1514,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1499
1514
|
try {
|
|
1500
1515
|
const machine = session.machine ?? localMachine();
|
|
1501
1516
|
if (session.surface === 'native' && !session.pane) {
|
|
1502
|
-
const reading = nativeReading(await readNative(machine, session.harness, session.session_id), session);
|
|
1517
|
+
const reading = nativeReading(await readNative(machine, session.harness, session.session_id, session.codex_home ?? null), session);
|
|
1503
1518
|
if (reading.alive == null) throw new Error(`close deferred: native liveness unknown for ${addressOf(session)}`);
|
|
1504
1519
|
if (reading.alive) {
|
|
1505
1520
|
const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
|
|
@@ -1627,10 +1642,15 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1627
1642
|
staged = []; watched = null;
|
|
1628
1643
|
const task = getTask(db, id);
|
|
1629
1644
|
const status = statusOf(db, task);
|
|
1630
|
-
|
|
1645
|
+
const handles = name => entry.workflow.statuses[status]?.on?.[name] || entry.workflow.on?.[name] || entry.workflow.run?.on?.[name];
|
|
1646
|
+
if (!handles('session_idle') && !handles('session_stalled')) return;
|
|
1631
1647
|
const runRow = currentRun(db, id);
|
|
1632
1648
|
let session = runRow?.metadata?.supercode;
|
|
1633
1649
|
if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
|
|
1650
|
+
if (task.paused || task.archived || task.detached || runRow.ended_at != null) {
|
|
1651
|
+
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null, watch_since: now() });
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1634
1654
|
// a machine this round did not read as online (offline, or its door silent) shows nothing of its session: no
|
|
1635
1655
|
// clock of the run moves on a round that could not see it
|
|
1636
1656
|
if (session.machine && !fleet.online.has(session.machine)) return;
|
|
@@ -1696,6 +1716,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1696
1716
|
// a reading that shows the session no longer waiting ends the prompt; a round that did not read it does not
|
|
1697
1717
|
if (session.held_notice && live && !asked) setRunSession(db, runRow.id, { held_notice: null });
|
|
1698
1718
|
if (asked) {
|
|
1719
|
+
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
|
|
1699
1720
|
// One prompt is one notice: the same wait (what it waits for, since when its harness says) on the same
|
|
1700
1721
|
// session, whichever of its cards a round reads first and however long it stands.
|
|
1701
1722
|
const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
|
|
@@ -1715,7 +1736,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1715
1736
|
return;
|
|
1716
1737
|
}
|
|
1717
1738
|
if (live?.turn === 'working' || delegated.length) {
|
|
1718
|
-
if (session.idle_since != null) setRunSession(db, runRow.id, { idle_since: null });
|
|
1739
|
+
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
|
|
1719
1740
|
retain(delegated.length ? 'delegated_work' : 'working', { delegated_sessions: delegated });
|
|
1720
1741
|
return;
|
|
1721
1742
|
}
|
|
@@ -1723,6 +1744,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1723
1744
|
// indefinitely. Bound the observed interval independently of transcript
|
|
1724
1745
|
// writes, and tell the manager once with the native facts we actually read.
|
|
1725
1746
|
if (live?.turn !== 'idle') {
|
|
1747
|
+
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
|
|
1726
1748
|
const since = Number(session.unknown_since ?? observedAt);
|
|
1727
1749
|
reading.unknown_since = since;
|
|
1728
1750
|
reading.quiet_seconds = Math.max(0, observedAt - since);
|
|
@@ -1747,6 +1769,29 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1747
1769
|
const interval = idleInterval(session, live, observedAt);
|
|
1748
1770
|
reading.idle_since = interval.since; reading.quiet_seconds = interval.quiet;
|
|
1749
1771
|
if (session.idle_since !== interval.since) setRunSession(db, runRow.id, { idle_since: interval.since });
|
|
1772
|
+
// The workflow declares the threshold, repeat interval, eligibility and
|
|
1773
|
+
// notice. The observer only supplies a watched native idle interval. This
|
|
1774
|
+
// path precedes heartbeat/nudge cooldown and unread checkout handling.
|
|
1775
|
+
if (handles('session_stalled')) {
|
|
1776
|
+
const limits = limitsOf(db, board, entry, task, session);
|
|
1777
|
+
const first = Number(limits.idle_notice), repeat = Number(limits.idle_notice_repeat);
|
|
1778
|
+
const nativeIdle = live?.turn_since_ms ?? null;
|
|
1779
|
+
const prior = session.idle_notice?.native_since_ms === nativeIdle ? session.idle_notice : null;
|
|
1780
|
+
const due = first > 0 && interval.quiet >= first && (!prior || (repeat > 0 && observedAt - prior.at >= repeat));
|
|
1781
|
+
if (due) try {
|
|
1782
|
+
const turnAt = Number(live?.turn_since_ms) || (lastAt > 0 ? Number(lastAtMs) : null);
|
|
1783
|
+
const payload = { ...reading, session: address, run_id: runRow.id, last_turn_at_ms: turnAt,
|
|
1784
|
+
last_turn_at: turnAt ? new Date(turnAt).toISOString() : null,
|
|
1785
|
+
last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior) };
|
|
1786
|
+
const result = fire(db, board, entry, id, 'session_stalled', { roles: ['platform'], payload });
|
|
1787
|
+
staged.push(...result.effects);
|
|
1788
|
+
setRunSession(db, runRow.id, { idle_notice: { native_since_ms: nativeIdle, since: interval.since, at: observedAt } });
|
|
1789
|
+
report.notified.push([id, 'session_stalled', address]);
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
if (!error.noTransition) report.failed.push([id, `session_stalled: ${String(error.message).slice(-200)}`]);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
if (!handles('session_idle')) return;
|
|
1750
1795
|
if (reading.quiet_seconds < settings.idleQuiet) { retain('below_threshold'); return; }
|
|
1751
1796
|
if (session.goal_nudged_at && observedAt - session.goal_nudged_at < settings.idleQuiet) { retain('nudge_cooldown'); return; }
|
|
1752
1797
|
// a session whose first input bounced was never told its work: its quiet is not idleness (recordBounces told its manager)
|
|
@@ -1801,7 +1846,7 @@ export async function sessionNotice(board, e) {
|
|
|
1801
1846
|
const address = e.address ?? addressOf(prior);
|
|
1802
1847
|
if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
1803
1848
|
const card = view(board, (db) => getTask(db, e.id));
|
|
1804
|
-
const subject = numbered(card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`);
|
|
1849
|
+
const subject = numbered(boardKey(board.root, board.slug), card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`);
|
|
1805
1850
|
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
|
|
1806
1851
|
tx(board, (db) => {
|
|
1807
1852
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
@@ -1822,7 +1867,7 @@ export async function sessionEffect(board, e) {
|
|
|
1822
1867
|
if (!address) throw new Error('actor session identity unavailable');
|
|
1823
1868
|
// filed in the actor's mailbox, which delivers it
|
|
1824
1869
|
const card = view(board, (db) => getTask(db, e.id));
|
|
1825
|
-
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`) });
|
|
1870
|
+
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(boardKey(board.root, board.slug), card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`) });
|
|
1826
1871
|
if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
|
|
1827
1872
|
return true;
|
|
1828
1873
|
}
|
|
@@ -1897,7 +1942,7 @@ function launcherCheck(db, task, lane, fleet) {
|
|
|
1897
1942
|
if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
|
|
1898
1943
|
const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
|
|
1899
1944
|
if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
|
|
1900
|
-
if (fleet.unknown?.has(machine)) return `machine ${machine}'s
|
|
1945
|
+
if (fleet.unknown?.has(machine)) return `machine ${machine}'s fleet record is not current: ${fleet.errors?.get(machine) ?? 'no current record'}; the card starts when it is`;
|
|
1901
1946
|
// a machine is scheduled only when it can take a board launch: its board process guard runs this harness, and its
|
|
1902
1947
|
// daemon keeps keyed launches (it reconciles them itself). One without them is not scheduled, and says why.
|
|
1903
1948
|
const can = fleet.capabilities?.get(machine);
|
|
@@ -1987,9 +2032,9 @@ function launchReading(fleet, session) {
|
|
|
1987
2032
|
}
|
|
1988
2033
|
|
|
1989
2034
|
/** How a keyed launch ended, as its machine recorded it; read only where the machine has the door (supervise). */
|
|
1990
|
-
async function launchReceipt(board, session) {
|
|
2035
|
+
async function launchReceipt(board, session, timeout = 30_000) {
|
|
1991
2036
|
const r = await doorOn(session.machine, 'harness.v1.launches.get',{key:session.launch_key},
|
|
1992
|
-
{ timeout
|
|
2037
|
+
{ timeout, as: boardAddress(board.label) });
|
|
1993
2038
|
if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1994
2039
|
if (r.stdout.trim() === 'null') return null;
|
|
1995
2040
|
const receipt = lastJson(r.stdout);
|
|
@@ -2105,7 +2150,10 @@ async function synchronizeAgents(board, report) {
|
|
|
2105
2150
|
await yieldToOwner();
|
|
2106
2151
|
const task=getTaskNow(board,assignment.target);
|
|
2107
2152
|
if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
|
|
2108
|
-
else if(task)
|
|
2153
|
+
else if(task){
|
|
2154
|
+
if(task.readable_id)renameCardAgent(board.root,assignment,task.readable_id);
|
|
2155
|
+
recordCardAgentAssignment(board,assignment);
|
|
2156
|
+
}
|
|
2109
2157
|
}
|
|
2110
2158
|
// What a creating or closing verb could not hand on (no daemon running then) is handed on here.
|
|
2111
2159
|
try{for(const pending of await handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
|
|
@@ -2145,8 +2193,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2145
2193
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
|
|
2146
2194
|
WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
|
|
2147
2195
|
AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
|
|
2148
|
-
// A keyed launch
|
|
2149
|
-
// the reading finds in it. Its machine's daemon reconciles the key itself (D137); no door is asked here. A launch with
|
|
2196
|
+
// A keyed launch is bound from the fleet reading, or its machine's exact native first-input receipt. A launch with
|
|
2150
2197
|
// no pane waits for its launch window (supervise surfaces it then); a pane read absent on its online machine after
|
|
2151
2198
|
// the grace ended before its session was bound.
|
|
2152
2199
|
for (const row of rows) {
|
|
@@ -2157,6 +2204,16 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2157
2204
|
&& ((s.pane && f.pane === s.pane) || (s.session_id && f.session_id === s.session_id)));
|
|
2158
2205
|
const sid = s.session_id ?? found?.address.split(':')[3];
|
|
2159
2206
|
if (found && sid) { bindLaunch(board, row, s, found.pane ?? s.pane, sid); continue; }
|
|
2207
|
+
// An app-server-owned Codex thread can confirm its first input before process discovery knows that thread.
|
|
2208
|
+
// Bind only this board's live issuance, its exact pane and the native user-message receipt, never a title match.
|
|
2209
|
+
if (!row.ended_at && s.launch_key && launchReading(fleet, s) === 'present') {
|
|
2210
|
+
const receipt = await launchReceipt(board, s, 5000);
|
|
2211
|
+
if (receipt?.by === boardAddress(board.label) && receipt.pane === s.pane && receipt.live === true
|
|
2212
|
+
&& receipt.inputDelivered === true && receipt.inputReceipt === 'native-user-message'
|
|
2213
|
+
&& typeof receipt.native_session === 'string' && receipt.native_session) {
|
|
2214
|
+
bindLaunch(board, row, s, s.pane, receipt.native_session); continue;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2160
2217
|
// An ended run's launch that bound no session within its window awaits nothing: the run is over, and its pane's
|
|
2161
2218
|
// close (closeRequested) needs no session. It was reported as awaiting its session every round, for good, once
|
|
2162
2219
|
// its machine refused that close (t_7ec85b6b run 1203, 2026-10-09).
|
|
@@ -2186,7 +2243,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2186
2243
|
}
|
|
2187
2244
|
}
|
|
2188
2245
|
|
|
2189
|
-
async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
|
|
2246
|
+
async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, orchestration } = {}) {
|
|
2190
2247
|
const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
|
|
2191
2248
|
// Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
|
|
2192
2249
|
// no machine default decides it.
|
|
@@ -2246,6 +2303,43 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2246
2303
|
// The pane id is chosen from the launch key and recorded before open runs, so a launch whose answer is lost (a
|
|
2247
2304
|
// timeout, a closed stream) is still found by its pane in a later round's fleet reading, and by its session id.
|
|
2248
2305
|
const openOwned = async (key, sid, mode, args) => {
|
|
2306
|
+
// Keeping a live pane never prepares or mutates a different route/home.
|
|
2307
|
+
if (harness === 'codex') {
|
|
2308
|
+
const profile = meta.agent_profile_intent;
|
|
2309
|
+
if (!profile || !orchestration) throw unavailable('headful Codex has no retained profile route; no shared-login pane was launched');
|
|
2310
|
+
const vault = new Map();
|
|
2311
|
+
refreshVault(orchestration, vault);
|
|
2312
|
+
const selected = { ...profile, name: lane.name, dir: lane.dir };
|
|
2313
|
+
const worker = { ...profile.worker, harness: 'codex', model: model ?? undefined };
|
|
2314
|
+
let resume = null;
|
|
2315
|
+
if (mode === 'resume') {
|
|
2316
|
+
const rows = await readNative(machine, 'codex', sid, prev?.codex_home ?? null);
|
|
2317
|
+
const paths = [...new Set(rows.filter(row => row.locator?.harness === 'codex' && row.locator?.session_id === sid
|
|
2318
|
+
&& row.locator.storage?.kind === 'file').map(row => row.locator.storage.path))];
|
|
2319
|
+
if (paths.length !== 1) throw unavailable('the resumed Codex has no unique physical transcript source; no pane was launched');
|
|
2320
|
+
resume = { source: paths[0], id: sid };
|
|
2321
|
+
}
|
|
2322
|
+
let route;
|
|
2323
|
+
if (machine === localMachine()) {
|
|
2324
|
+
route = codexPaneRoute(selected, worker, vault);
|
|
2325
|
+
if (resume) projectCodexPaneResume(route.env.CODEX_HOME, resume.source, resume.id);
|
|
2326
|
+
} else {
|
|
2327
|
+
const prepared = await doorOn(machine, 'harness.v1.codex.pane.prepare', {
|
|
2328
|
+
scope: `${localMachine()}:${board.root}:${lane.name}`, machine: localMachine(),
|
|
2329
|
+
cwd: task.workspace_path,
|
|
2330
|
+
...codexPaneInputs(selected, worker, vault), ...(resume ? { resume } : {}),
|
|
2331
|
+
}, { timeout: 30_000, as: boardAddress(board.label) });
|
|
2332
|
+
route = prepared.code === 0 ? json(prepared.stdout.trim(), null) : null;
|
|
2333
|
+
if (!route?.env?.CODEX_HOME || !Array.isArray(route.args))
|
|
2334
|
+
throw unavailable(`the destination did not prepare the login-free Codex route: ${prepared.stderr.trim() || 'invalid route receipt'}`);
|
|
2335
|
+
}
|
|
2336
|
+
meta.codex_home = route.env.CODEX_HOME;
|
|
2337
|
+
const separator = args.indexOf('--');
|
|
2338
|
+
if (separator < 0) throw unavailable('the headful Codex launch has no native argument boundary');
|
|
2339
|
+
args = [...args.slice(0, separator), ...Object.entries(route.env).flatMap(([name, value]) => ['--env', `${name}=${value}`]),
|
|
2340
|
+
...args.slice(separator), ...route.args];
|
|
2341
|
+
if (machine === localMachine()) declareWorkerHome({ harness: 'codex', env: route.env, cwd: task.workspace_path });
|
|
2342
|
+
}
|
|
2249
2343
|
const chosen = `p_${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
2250
2344
|
tx(board, (db) => {
|
|
2251
2345
|
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 });
|
|
@@ -2277,12 +2371,12 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2277
2371
|
throw Object.assign(new Error(reason), { launchPending: true });
|
|
2278
2372
|
};
|
|
2279
2373
|
let session = null;
|
|
2280
|
-
let replaced = null;
|
|
2374
|
+
let replaced = null, predecessor = null;
|
|
2281
2375
|
// A session in no pane is read on its machine before anything replaces it. One the board released (an adopted
|
|
2282
2376
|
// session it did not launch, left running at its run's end) is never resumed beside itself: while it runs it is kept,
|
|
2283
2377
|
// and the card's next run is delivered into it, as into any kept session.
|
|
2284
2378
|
if (prev?.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
|
|
2285
|
-
const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id), prev);
|
|
2379
|
+
const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id, prev.codex_home ?? null), prev);
|
|
2286
2380
|
if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
|
|
2287
2381
|
fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
|
|
2288
2382
|
if (reading.alive && !prev.released && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
|
|
@@ -2291,6 +2385,32 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2291
2385
|
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`);
|
|
2292
2386
|
replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
|
|
2293
2387
|
}
|
|
2388
|
+
else if (prev && meta.agent_id && (predecessor = otherAgent(board, prev, meta))) {
|
|
2389
|
+
// A session is continued only as the agent it belongs to: the daemon refuses any other ("session … is agent …'s;
|
|
2390
|
+
// nothing resumes it as agent …"), and keeping it would deliver this run into another agent's session. A card's role
|
|
2391
|
+
// agent ends with the card (finishCardAgentAssignment) and an ended agent is never started again (agents.mjs
|
|
2392
|
+
// localAgent: the key's next generation is minted in its place, with fresh memory), so a reopened card's run is a new
|
|
2393
|
+
// agent, as is one the manager reassigned to another agent. Its predecessor's session is let go and the run starts
|
|
2394
|
+
// fresh from the card's text and thread, as a lane of another harness does (VO-2858 t_cc590655: reopened at 22:57Z on
|
|
2395
|
+
// 2026-10-17, runs 1545 and 1546 ended spawn_failed resuming codex 01a14bac, agent_21e274c9's, as agent_00246aaa).
|
|
2396
|
+
// Nothing is closed here: every ended run's pane was confirmed closed above (`pending`), so a session still open at
|
|
2397
|
+
// this point is one the board does not close (a close refused for good, which runs on; a pane a person continued by
|
|
2398
|
+
// hand, swapped in above), and is released as an adopted one is (closeRequested), never closed. The release is
|
|
2399
|
+
// written to the runs that hold it before the fresh session starts, so the slot's next launch, which reads the fresh
|
|
2400
|
+
// session as the slot's last, finds no pane of them still owed a close (`pending`); a Codex launch binds from a later
|
|
2401
|
+
// round's reading, so the release cannot wait for this launch's own record.
|
|
2402
|
+
tx(board, (db) => {
|
|
2403
|
+
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 });
|
|
2404
|
+
for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
|
|
2405
|
+
const held = json(r.metadata, {})?.supercode;
|
|
2406
|
+
if (held?.session_id !== prev.session_id || (held.slot ?? 'worker') !== slot || held.close_confirmed_at || held.close_transferred_to) continue;
|
|
2407
|
+
const at = now();
|
|
2408
|
+
setRunSession(db, r.id, { ...held, close_requested: held.close_requested ?? at, closed_at: held.closed_at ?? at, close_confirmed_at: at, close_error: null, released: true });
|
|
2409
|
+
event(db, task.id, 'session_released', { session_id: held.session_id, pane: held.pane ?? null, machine: held.machine ?? null, reason: `it is agent ${predecessor}'s; run ${runId} is agent ${meta.agent_id}'s and starts fresh, and it is left running` }, r.id);
|
|
2410
|
+
}
|
|
2411
|
+
});
|
|
2412
|
+
replaced = { session_id: prev.session_id, why: `it is agent ${predecessor}'s, and this run is agent ${meta.agent_id}'s`, letGo: true };
|
|
2413
|
+
}
|
|
2294
2414
|
else if (prev && (prev.harness ?? 'claude-code') !== harness) {
|
|
2295
2415
|
// the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
|
|
2296
2416
|
// are its context), and the last session, if still open, is closed so two harnesses never work one card
|
|
@@ -2303,7 +2423,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2303
2423
|
if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
|
|
2304
2424
|
if (next === 'keep') {
|
|
2305
2425
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
2306
|
-
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, unknown_since: _us, unknown_notice: _un, work_progress: _wp, 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, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, ...kept } = prev;
|
|
2426
|
+
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, 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, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, ...kept } = prev;
|
|
2307
2427
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
2308
2428
|
// a kept session is told what this run is for (its card came back to it, with what happened since), only while
|
|
2309
2429
|
// this run is still the card's current one (its pane and native reads were awaited)
|
|
@@ -2343,7 +2463,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2343
2463
|
letGo: recoversRuns(workflowFor(board.root)) && Boolean(prev.resume_failed_before_live) && Boolean(prev.close_confirmed_at) };
|
|
2344
2464
|
} else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
|
|
2345
2465
|
// a card keeps its context: a slot's session that cannot be kept or resumed stops the launch, and only a session the
|
|
2346
|
-
// workflow let go (`forget_session`)
|
|
2466
|
+
// workflow let go (`forget_session`), its manager did (a reassignment to another harness's lane), or that belongs to
|
|
2467
|
+
// another agent than the run's (otherAgent) is replaced
|
|
2347
2468
|
if (replaced && !prev?.forgotten && !replaced.letGo) {
|
|
2348
2469
|
const failed = new Error(`session ${replaced.session_id} (${slot}) could not be continued: ${replaced.why}. The board keeps a card's context and starts a new session only when the workflow lets this one go`);
|
|
2349
2470
|
failed.resumeFailed = { session_id: replaced.session_id, slot, why: replaced.why };
|
|
@@ -2352,8 +2473,15 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2352
2473
|
if (!session) {
|
|
2353
2474
|
const sid = how.namesItself ? null : randomUUID();
|
|
2354
2475
|
const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
|
|
2355
|
-
session
|
|
2356
|
-
|
|
2476
|
+
// A fresh session is named for its card (`vo-856-3f`; a reviewer's `vo-856-reviewer-3f`), where its harness takes a
|
|
2477
|
+
// name, with the start of its id after it as the name's own suffix; a kept or resumed session keeps its name.
|
|
2478
|
+
const handle = cardHandleOf(board, task.id), ref = handle?.toUpperCase();
|
|
2479
|
+
const name = handle ? `${handle}${slot === 'worker' ? '' : `-${slot}`}-${(sid ?? randomUUID()).slice(0, 2)}` : null;
|
|
2480
|
+
const named = name && NAME_ARGUMENTS[harness] ? NAME_ARGUMENTS[harness](name) : [];
|
|
2481
|
+
// Codex names the thread after its native first-input receipt identifies it; Teams uses thread/name/set on
|
|
2482
|
+
// this launch's own app-server, then reads the name back. Continuations receive no naming hint.
|
|
2483
|
+
session = await openOwned(launchKey, sid, 'fresh', [...base, ...(name && harness === 'codex' ? ['--env', `SUPERCODE_SESSION_NAME=${name}`] : []), '--key', launchKey,
|
|
2484
|
+
'--input', input ?? `Card ${ref ?? task.id}${ref ? ` (${task.id})` : ''}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...named, ...tail]);
|
|
2357
2485
|
if (replaced) session.replaces = replaced.session_id;
|
|
2358
2486
|
}
|
|
2359
2487
|
session.harness = session.harness ?? harness;
|
|
@@ -2384,6 +2512,16 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2384
2512
|
return session;
|
|
2385
2513
|
}
|
|
2386
2514
|
|
|
2515
|
+
/** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
|
|
2516
|
+
* owner of the session where it records one in this run's org, else the agent its own run ran as. Null when it is
|
|
2517
|
+
* this run's agent, or no agent is known for it (a session from before card agents). */
|
|
2518
|
+
function otherAgent(board, prev, meta) {
|
|
2519
|
+
let owner = null;
|
|
2520
|
+
try { owner = agentRunForSession(board.root, [`${prev.harness ?? 'claude-code'}:${prev.session_id}`]); } catch { /* conflicting owners: its run's record */ }
|
|
2521
|
+
const agent = owner && owner.org === meta.agent_org ? owner.agent_id : prev.agent_id ?? null;
|
|
2522
|
+
return agent && agent !== meta.agent_id ? agent : null;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2387
2525
|
/**
|
|
2388
2526
|
* What a slot's last session on the card's machine is to the card's next run, given whether it is live (its pane, else
|
|
2389
2527
|
* its native process): an open session that is live is kept, and the run is delivered into it; a live pane holding a
|
|
@@ -2472,7 +2610,7 @@ function rotateLog(file, max, backups) {
|
|
|
2472
2610
|
/** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
|
|
2473
2611
|
async function resolveLocalWorkspace(board, task) {
|
|
2474
2612
|
const root = board.root;
|
|
2475
|
-
if (task.workspace_kind !== 'scratch') return (await resolveWorkspace(task, true)).path;
|
|
2613
|
+
if (task.workspace_kind !== 'scratch') return (await resolveWorkspace({ ...task, handle: cardHandleOf(board, task.id) }, true)).path;
|
|
2476
2614
|
const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
|
|
2477
2615
|
mkdirSync(dir, { recursive: true });
|
|
2478
2616
|
return dir;
|
|
@@ -2664,7 +2802,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2664
2802
|
});
|
|
2665
2803
|
progress.claimed = request !== null;
|
|
2666
2804
|
// The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
|
|
2667
|
-
const agent = lane ? await localCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole
|
|
2805
|
+
const agent = lane ? await localCardAgent({ root: board.root, handle: cardHandleOf(board, id), readableId: readableIdOf(board, id), org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole}) : null;
|
|
2668
2806
|
if(agent)recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
|
|
2669
2807
|
// A session another agent already owns is refused before anything is recorded: the run would name one agent and the
|
|
2670
2808
|
// agent store another, and every round after would trip on it.
|
|
@@ -2865,7 +3003,7 @@ export async function fileBoardMail(board, report, taskId = null) {
|
|
|
2865
3003
|
const first = mail.members[0];
|
|
2866
3004
|
try {
|
|
2867
3005
|
const task = view(board, db => getTask(db, first.task_id));
|
|
2868
|
-
const subject = numbered(task?.number, mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`);
|
|
3006
|
+
const subject = numbered(boardKey(board.root, board.slug), mail.requester && !mail.message_id && new Set(mail.members.map((m) => m.task_id)).size > 1 ? null : task?.number, mail.requester ? (mail.message_id ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${mail.title ?? mail.requester} · ${mail.members.length} card(s)`) : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`);
|
|
2869
3007
|
const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
2870
3008
|
tx(board, db => {
|
|
2871
3009
|
if (filed.detached) {
|
|
@@ -2896,7 +3034,7 @@ export function noticeOf(entry, audience, kind, task, payload, comment = null) {
|
|
|
2896
3034
|
if (template === undefined || template === null) return null;
|
|
2897
3035
|
const cut = (text, n) => String(text ?? '').slice(0, Number(n));
|
|
2898
3036
|
const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
|
|
2899
|
-
return text.trim() ? numbered(task?.number, text) : null;
|
|
3037
|
+
return text.trim() ? numbered(task?.key, task?.number, text) : null;
|
|
2900
3038
|
}
|
|
2901
3039
|
|
|
2902
3040
|
/**
|
|
@@ -2923,6 +3061,42 @@ function ensureSubscriptions(board, entry) {
|
|
|
2923
3061
|
/** The event kinds an audience hears (the workflow's notify). */
|
|
2924
3062
|
const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
|
|
2925
3063
|
|
|
3064
|
+
/** Positive liveness from the same machine door as `message list`, once per delivery pass. Stale remote rows prove nothing. */
|
|
3065
|
+
async function noticeSessions() {
|
|
3066
|
+
const r = await runtimeDoor(null, 'harness.v1.mail.list', {}, { timeout: 5_000 });
|
|
3067
|
+
const rows = r.code === 0 ? lastJson(r.stdout)?.rows : null;
|
|
3068
|
+
return new Set((Array.isArray(rows) ? rows : [])
|
|
3069
|
+
.filter(row => sessionAt(row.address) && row.stale === undefined && !row.error && row.delivery
|
|
3070
|
+
&& !['stale', 'unavailable', 'unknown'].includes(row.status))
|
|
3071
|
+
.map(row => row.address));
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
/** Retain a subscription's wait across passes and owner restarts; only transitions enter the log report. */
|
|
3075
|
+
function noticeWait(board, sub, report, reason = null) {
|
|
3076
|
+
if (reason === null && !sub.delivery_metadata?.notice_wait) return;
|
|
3077
|
+
const changed = tx(board, db => {
|
|
3078
|
+
const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
|
|
3079
|
+
const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
|
|
3080
|
+
const row = db.prepare(`SELECT delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3081
|
+
if (!row) return false;
|
|
3082
|
+
const metadata = json(row.delivery_metadata, {}) ?? {};
|
|
3083
|
+
if ((metadata.role ?? 'creator') !== (sub.delivery_metadata?.role ?? 'creator')) return false;
|
|
3084
|
+
const previous = metadata.notice_wait;
|
|
3085
|
+
if ((previous?.reason ?? null) === reason) return false;
|
|
3086
|
+
if (reason === null) delete metadata.notice_wait;
|
|
3087
|
+
else metadata.notice_wait = { reason, since: now() };
|
|
3088
|
+
db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`).run(JSON.stringify(metadata), ...identity);
|
|
3089
|
+
event(db, sub.task_id, reason === null ? 'notice_resumed' : 'notice_waiting', {
|
|
3090
|
+
address: sub.chat_id, thread_id: sub.thread_id ?? '', reason, previous: previous?.reason ?? null,
|
|
3091
|
+
});
|
|
3092
|
+
return true;
|
|
3093
|
+
});
|
|
3094
|
+
if (changed) {
|
|
3095
|
+
if (reason !== null) report.undelivered.push([sub.task_id, sub.chat_id, reason]);
|
|
3096
|
+
else (report.notices_resumed ??= []).push([sub.task_id, sub.chat_id]);
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
|
|
2926
3100
|
/**
|
|
2927
3101
|
* The board's record of each session address: `held`, every address an unended or current run works in, and `latest`,
|
|
2928
3102
|
* the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
|
|
@@ -2945,6 +3119,21 @@ function sessionRecord(db) {
|
|
|
2945
3119
|
*/
|
|
2946
3120
|
const recordedEnded = ({ held, latest }, address) => Boolean(sessionAt(address)) && !held.has(address) && Boolean(latest.get(address)?.session.close_confirmed_at);
|
|
2947
3121
|
|
|
3122
|
+
/**
|
|
3123
|
+
* Whether the board has asked to close the session at `address` and the close has not landed: the latest run that names
|
|
3124
|
+
* it asked the close, nothing confirmed it, it was not released to a person, no unended or current run works in it, and
|
|
3125
|
+
* the close was not refused for good (closeRefused: the board stops trying, the session lives on and is told as before).
|
|
3126
|
+
* A close waits for the session's turn to end (turnAtClose), and a notice filed in that wait is left unread in its
|
|
3127
|
+
* mailbox once the close lands, where the machine's health reads it as main work waiting on a session that will never
|
|
3128
|
+
* answer (VO-1221 t_4007dd5b: its machine_set notice m-f91ba984 was filed to fad0018e at 20:00:05Z on 2026-10-17, the
|
|
3129
|
+
* second the deferred close of that session landed, and raised aarons-mac-mini's CRIT agent-main-advances).
|
|
3130
|
+
*/
|
|
3131
|
+
const closePending = ({ held, latest }, address) => {
|
|
3132
|
+
const session = latest.get(address)?.session;
|
|
3133
|
+
return Boolean(sessionAt(address)) && !held.has(address) && Boolean(session?.close_requested) && !session.close_confirmed_at && !session.released
|
|
3134
|
+
&& !closeRefused(session);
|
|
3135
|
+
};
|
|
3136
|
+
|
|
2948
3137
|
/**
|
|
2949
3138
|
* The worker subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event
|
|
2950
3139
|
* per session naming its cards. A session has ended when no unended or current run works in it, the fleet does not list
|
|
@@ -2989,8 +3178,10 @@ async function endWorkerSubscriptions(board, fleet, report) {
|
|
|
2989
3178
|
* write under the notice's fixed key; the mailbox delivers it to the session, on this machine or through Teams on
|
|
2990
3179
|
* another. Each subscription's notices go in order, and its cursor moves past a notice once it is filed (or passed
|
|
2991
3180
|
* over); a filing that failed here leaves the cursor before it, and the next pass files it. A session the board's own
|
|
2992
|
-
* record says has ended (recordedEnded) is filed nothing, in any role, until it is read running again
|
|
2993
|
-
*
|
|
3181
|
+
* record says has ended (recordedEnded) is filed nothing, in any role, until it is read running again, and neither is a
|
|
3182
|
+
* worker whose close the board has asked and not yet confirmed (closePending): its cursor stays, so a session resumed
|
|
3183
|
+
* later is told what it missed. The mail router's current session listing is also positive liveness evidence,
|
|
3184
|
+
* including when pane/discovery liveness cannot be read. An unread listing never proves a session absent.
|
|
2994
3185
|
*/
|
|
2995
3186
|
async function notify(board, fleet, report, entry) {
|
|
2996
3187
|
await endWorkerSubscriptions(board, fleet, report);
|
|
@@ -3001,34 +3192,45 @@ async function notify(board, fleet, report, entry) {
|
|
|
3001
3192
|
if (view(board, db => personCustody(db, sub.chat_id))) continue;
|
|
3002
3193
|
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
3003
3194
|
const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
|
|
3004
|
-
|
|
3005
|
-
|
|
3195
|
+
const kinds = heardBy(entry, role);
|
|
3196
|
+
if (!kinds.length) { noticeWait(board, sub, report); continue; }
|
|
3197
|
+
// Read pending news before liveness: a subscription with nothing to file is not waiting on delivery.
|
|
3198
|
+
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 LIMIT 64`)
|
|
3199
|
+
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
3200
|
+
if (!events.length) { noticeWait(board, sub, report); continue; }
|
|
3201
|
+
// Events name the acting native session; subscriptions may name its agent.
|
|
3202
|
+
// Use the daemon's retained binding, including former mains, for both
|
|
3203
|
+
// coalescing and delivery. An unread binding proves no additional alias.
|
|
3204
|
+
const recipientActors = new Set([sub.chat_id]);
|
|
3205
|
+
if (agentAt(sub.chat_id)) {
|
|
3206
|
+
await agentMain(sub.chat_id);
|
|
3207
|
+
for (const address of agentMains.get(sub.chat_id)?.sessions ?? []) recipientActors.add(address);
|
|
3208
|
+
}
|
|
3209
|
+
const ownAct = payload => Boolean(payload?.by && recipientActors.has(payload.by));
|
|
3210
|
+
// a worker the board is closing is filed nothing: its cursor stays, so a close that is refused or a session resumed
|
|
3211
|
+
// is told what it missed, and a close that lands ends its subscription (endWorkerSubscriptions)
|
|
3212
|
+
if (role === 'worker' && closePending(record, sub.chat_id)) {
|
|
3213
|
+
noticeWait(board, sub, report, 'its session close is pending; its notices wait'); continue;
|
|
3214
|
+
}
|
|
3215
|
+
// Native notices wait with their cursor intact until the fleet, discovery or mail inventory proves liveness.
|
|
3006
3216
|
// Agent, board and operator mailboxes route independently of a native PID.
|
|
3007
3217
|
if (sessionAt(sub.chat_id)) {
|
|
3008
|
-
const alive = fleet.ok ? await subscriberAlive(fleet, sub.chat_id) : null;
|
|
3218
|
+
const alive = fleet.noticeAlive?.has(sub.chat_id) ? true : fleet.ok ? await subscriberAlive(fleet, sub.chat_id) : null;
|
|
3009
3219
|
if (alive !== true) {
|
|
3010
|
-
|
|
3220
|
+
noticeWait(board, sub, report, alive === false ? 'its session is not running; its notices wait' : 'its liveness could not be read; its notices wait until it can be');
|
|
3011
3221
|
continue;
|
|
3012
3222
|
}
|
|
3013
3223
|
}
|
|
3014
|
-
//
|
|
3015
|
-
// sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
|
|
3016
|
-
// has its subscription moved when the new one starts
|
|
3224
|
+
// A missing or unread session keeps its cursor and subscription until a positive reading returns.
|
|
3017
3225
|
const ended = recordedEnded(record, sub.chat_id);
|
|
3018
3226
|
if (!sessionAt(sub.chat_id) && (role !== 'creator' || ended) && fleet.ok) {
|
|
3019
3227
|
const alive = await subscriberAlive(fleet, sub.chat_id);
|
|
3020
|
-
if (alive === false) continue;
|
|
3228
|
+
if (alive === false) { noticeWait(board, sub, report, 'its session is not running; its notices wait'); continue; }
|
|
3021
3229
|
// unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
|
|
3022
|
-
if (alive === null) {
|
|
3230
|
+
if (alive === null) { noticeWait(board, sub, report, 'its liveness could not be read; its notices wait until it can be'); continue; }
|
|
3023
3231
|
} else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
|
|
3024
|
-
const kinds = heardBy(entry, role);
|
|
3025
|
-
if (!kinds.length) continue;
|
|
3026
|
-
// The retained cursor resumes the next page; one native read never materializes the whole unread history.
|
|
3027
|
-
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 LIMIT 64`)
|
|
3028
|
-
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
3029
|
-
if (!events.length) continue;
|
|
3030
3232
|
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
|
|
3031
|
-
const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, status: statusOf(db, t), stored_as: t.status } : t; });
|
|
3233
|
+
const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug), status: statusOf(db, t), stored_as: t.status } : t; });
|
|
3032
3234
|
// a worker whose run has ended is not woken by a notice (a second session would start working the card beside
|
|
3033
3235
|
// the one the next run gets): the notice is filed for its next turn, and the next run's brief carries the card's
|
|
3034
3236
|
// thread. A subtask has no run of its own: its worker works in its arc's run.
|
|
@@ -3046,18 +3248,18 @@ async function notify(board, fleet, report, entry) {
|
|
|
3046
3248
|
// is its own fact, keyed by the card it names); a comment or an answer is always its own message. Only events that
|
|
3047
3249
|
// would be told count: a stale one or the recipient's own act never supersedes one that would be.
|
|
3048
3250
|
const subjectOf = (ev) => { const p = json(ev.payload, {}) ?? {}; return ['commented', 'answered'].includes(ev.kind) ? `${ev.kind}:${ev.id}` : `${ev.kind}:${p.subtask ?? p.parent ?? p.child ?? ''}`; };
|
|
3049
|
-
const latest = new Map(events.filter((ev) => !stale(ev) && !(
|
|
3251
|
+
const latest = new Map(events.filter((ev) => !stale(ev) && !ownAct(json(ev.payload, {}))).map((ev) => [subjectOf(ev), ev.id]));
|
|
3050
3252
|
let cursor = Number(sub.last_event_id);
|
|
3051
3253
|
try {
|
|
3052
3254
|
for (const ev of events) {
|
|
3053
3255
|
const p = json(ev.payload, {}) ?? {};
|
|
3054
3256
|
if (stale(ev)) { cursor = ev.id; report.stale_notices_passed = (report.stale_notices_passed ?? 0) + 1; continue; }
|
|
3055
3257
|
// nobody is told of their own act
|
|
3056
|
-
if (p
|
|
3258
|
+
if (ownAct(p)) { cursor = ev.id; continue; }
|
|
3057
3259
|
if (latest.get(subjectOf(ev)) !== ev.id) { cursor = ev.id; report.superseded_notices_passed = (report.superseded_notices_passed ?? 0) + 1; continue; }
|
|
3058
3260
|
let text;
|
|
3059
3261
|
if (ev.kind === 'commented') {
|
|
3060
|
-
if (p.author
|
|
3262
|
+
if (recipientActors.has(p.author)) { cursor = ev.id; continue; }
|
|
3061
3263
|
const c = board.db.prepare('SELECT author, body FROM task_comments WHERE task_id = ? AND author = ? AND created_at >= ? ORDER BY id LIMIT 1').get(sub.task_id, p.author, ev.created_at);
|
|
3062
3264
|
text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
|
|
3063
3265
|
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
@@ -3070,7 +3272,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3070
3272
|
if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
|
|
3071
3273
|
const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
|
|
3072
3274
|
// filed under its fixed key: a second pass filing the same notice files nothing new
|
|
3073
|
-
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: numbered(task?.number, `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`),
|
|
3275
|
+
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: numbered(task?.key, task?.number, `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`),
|
|
3074
3276
|
eligible: () => view(board, db => {
|
|
3075
3277
|
const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3076
3278
|
return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
|
|
@@ -3081,8 +3283,9 @@ async function notify(board, fleet, report, entry) {
|
|
|
3081
3283
|
cursor = ev.id;
|
|
3082
3284
|
forward(cursor);
|
|
3083
3285
|
}
|
|
3286
|
+
noticeWait(board, sub, report);
|
|
3084
3287
|
} catch (error) {
|
|
3085
|
-
|
|
3288
|
+
noticeWait(board, sub, report, String(error.message).slice(-200));
|
|
3086
3289
|
}
|
|
3087
3290
|
forward(cursor);
|
|
3088
3291
|
// a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
|
|
@@ -3123,7 +3326,7 @@ export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
|
3123
3326
|
if (sub.platform === 'supercode' || !adapter) continue;
|
|
3124
3327
|
const events = board.db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`).all(sub.task_id, sub.last_event_id, ...kinds);
|
|
3125
3328
|
if (!events.length) continue;
|
|
3126
|
-
const task = view(board, (db) => getTask(db, sub.task_id));
|
|
3329
|
+
const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug) } : t; });
|
|
3127
3330
|
let cursor = Number(sub.last_event_id);
|
|
3128
3331
|
let failed = false;
|
|
3129
3332
|
for (const ev of events) {
|
|
@@ -3357,7 +3560,7 @@ async function turnInFlight(board, id, fleet, heldSince = null) {
|
|
|
3357
3560
|
if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
|
|
3358
3561
|
let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
|
|
3359
3562
|
if (live?.turn == null && session.surface === 'native') try {
|
|
3360
|
-
const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id), session).row?.activity;
|
|
3563
|
+
const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, session.codex_home ?? null), session).row?.activity;
|
|
3361
3564
|
live = activity ? { turn: activity.turn ?? null, turn_trigger: activity.evidence?.turn_trigger ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null } : live;
|
|
3362
3565
|
} catch { /* unread: waits on nothing */ }
|
|
3363
3566
|
const turn = live?.turn ?? null;
|
|
@@ -3382,6 +3585,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3382
3585
|
const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
3383
3586
|
WHERE c.subtask_of IS NULL AND c.every IS NULL
|
|
3384
3587
|
${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
|
|
3588
|
+
// Review waits only someone's act clears, and the cards read this round in a state that is no such wait (surfaceReviewStalls)
|
|
3589
|
+
const stalls = [], settled = [];
|
|
3385
3590
|
for (const { id } of candidates) {
|
|
3386
3591
|
const eligible = db => {
|
|
3387
3592
|
const task = getTask(db, id);
|
|
@@ -3395,22 +3600,30 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3395
3600
|
return open.length ? open : true;
|
|
3396
3601
|
};
|
|
3397
3602
|
const ready = view(board, eligible);
|
|
3398
|
-
if (!ready) continue;
|
|
3399
|
-
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; }
|
|
3603
|
+
if (!ready) { settled.push(id); continue; }
|
|
3604
|
+
if (ready !== true) { settled.push(id); report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
|
|
3400
3605
|
let payload = null;
|
|
3401
3606
|
try {
|
|
3402
3607
|
payload = await openReviewPullRequests(board, id);
|
|
3403
|
-
// The arc's open PRs ask for one review only while some head
|
|
3404
|
-
// requested the next run is the implementer's, until
|
|
3608
|
+
// The arc's open PRs ask for one review only while the last verdict is answered or some head is new work: after
|
|
3609
|
+
// changes are requested the next run is the implementer's, until it closes the verdict's tasks or changes the
|
|
3610
|
+
// source (reviewDue).
|
|
3405
3611
|
// An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
|
|
3406
3612
|
if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
|
|
3407
3613
|
// An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
|
|
3408
3614
|
// session's done request and the manager's or creator's `complete`, and the round says that (volter-org
|
|
3409
3615
|
// t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
|
|
3410
3616
|
if (!payload) { report.queued.push([id, passedReview(board, id) ? 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`' : 'review waits: no open or unjudged merged PR on its declared branches, and no completed disposition']); continue; }
|
|
3411
|
-
if (payload.waits) {
|
|
3617
|
+
if (payload.waits) {
|
|
3618
|
+
report.queued.push([id, `review waits: ${payload.waits}`]);
|
|
3619
|
+
stalls.push({ id, key: `no_work_branch:${lastReviewEvent(board, id)}`, why: `its review waits: ${payload.waits}` });
|
|
3620
|
+
payload = null; continue;
|
|
3621
|
+
}
|
|
3412
3622
|
if (!payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
3413
|
-
|
|
3623
|
+
const why = `every open PR head (${payload.pr.join(', ')}) has a verdict, carries the source tree its verdict judged, or predates it; push a commit that changes the source, or close the tasks that own the last verdict's findings`;
|
|
3624
|
+
report.queued.push([id, `review waits: ${why}`]);
|
|
3625
|
+
// After a pass the arc's own session merges and asks for done; any other verdict nothing answered waits on an act
|
|
3626
|
+
if (!passedReview(board, id)) stalls.push({ id, key: `verdict:${lastReviewEvent(board, id)}`, why: `its review waits: ${why}. Every task is closed, so no session of the arc is due to act on it.` });
|
|
3414
3627
|
continue;
|
|
3415
3628
|
}
|
|
3416
3629
|
// A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
|
|
@@ -3449,11 +3662,66 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3449
3662
|
}));
|
|
3450
3663
|
});
|
|
3451
3664
|
if (moved) {
|
|
3665
|
+
settled.push(id);
|
|
3452
3666
|
effects.push(...moved.effects);
|
|
3453
3667
|
(report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
|
|
3454
3668
|
}
|
|
3455
3669
|
} catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
|
|
3456
3670
|
}
|
|
3671
|
+
if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
/** The id of the arc's last review event (requested, changes requested, passed, voided), or 0: a review wait's key
|
|
3675
|
+
* carries it, so the wait a new verdict or request leaves is a new wait. */
|
|
3676
|
+
const lastReviewEvent = (board, id) => board.db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided')").get(id)?.id ?? 0;
|
|
3677
|
+
|
|
3678
|
+
/** At most this many review waits are told per round; the rest are told in the rounds after, in candidate order. */
|
|
3679
|
+
const REVIEW_WAITS_TOLD_PER_ROUND = 8;
|
|
3680
|
+
/**
|
|
3681
|
+
* A review wait that only someone's act clears (the last verdict asked for changes and nothing since answers it; the
|
|
3682
|
+
* card has no work branch to review) reaches the board's manager through the board's own mail, as an incident does
|
|
3683
|
+
* (surfaceIncident's `board_message`, filed by the delivery pass's fileBoardMail): the dispatcher reads these waits every
|
|
3684
|
+
* round, and before this only its log heard them (VO-2858 t_cc590655, 2026-10-17).
|
|
3685
|
+
*
|
|
3686
|
+
* Each is told once. The board_message is the record: it carries the wait's key (its kind and the arc's last review
|
|
3687
|
+
* event), and while the card's last told key is the same and no `review_wait_cleared` follows it, nothing is told
|
|
3688
|
+
* again, across rounds and restarts. A card read this round in a state that is no such wait (no longer reviewable from
|
|
3689
|
+
* where it stands, an open task holding it, or its review requested: `settled`) clears its told wait once, so the same
|
|
3690
|
+
* wait coming back is told again. An unread round (a GitHub error, a session in a turn) neither tells nor clears.
|
|
3691
|
+
* One round tells at most REVIEW_WAITS_TOLD_PER_ROUND, and the waits told together share one batch, so the manager
|
|
3692
|
+
* gets one mail listing them (pendingRequestMail groups a batch by recipient and requester).
|
|
3693
|
+
*/
|
|
3694
|
+
function surfaceReviewStalls(board, entry, stalls, settled, report) {
|
|
3695
|
+
const to = [].concat(entry.params.managers ?? []).find(Boolean);
|
|
3696
|
+
if (!to || (!stalls.length && !settled.length)) return;
|
|
3697
|
+
// the key of the card's told review wait not cleared since, or null
|
|
3698
|
+
const told = (db, id) => {
|
|
3699
|
+
const last = db.prepare("SELECT id, json_extract(payload, '$.review_wait') AS key FROM task_events WHERE task_id = ? AND kind = 'board_message' AND json_extract(payload, '$.review_wait') IS NOT NULL ORDER BY id DESC LIMIT 1").get(id);
|
|
3700
|
+
if (!last) return null;
|
|
3701
|
+
const cleared = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_wait_cleared'").get(id)?.id ?? 0;
|
|
3702
|
+
return cleared > last.id ? null : last.key;
|
|
3703
|
+
};
|
|
3704
|
+
// read first: a round with nothing to tell or clear opens no write
|
|
3705
|
+
const plan = view(board, (db) => ({
|
|
3706
|
+
clear: settled.filter((id) => told(db, id)),
|
|
3707
|
+
tell: stalls.filter((stall) => told(db, stall.id) !== stall.key),
|
|
3708
|
+
}));
|
|
3709
|
+
if (plan.tell.length > REVIEW_WAITS_TOLD_PER_ROUND) report.review_waits_deferred = plan.tell.length - REVIEW_WAITS_TOLD_PER_ROUND;
|
|
3710
|
+
plan.tell = plan.tell.slice(0, REVIEW_WAITS_TOLD_PER_ROUND);
|
|
3711
|
+
if (!plan.clear.length && !plan.tell.length) return;
|
|
3712
|
+
tx(board, (db) => {
|
|
3713
|
+
for (const id of plan.clear) {
|
|
3714
|
+
const key = told(db, id);
|
|
3715
|
+
if (key) event(db, id, 'review_wait_cleared', { key }, null);
|
|
3716
|
+
}
|
|
3717
|
+
for (const stall of plan.tell) {
|
|
3718
|
+
if (told(db, stall.id) === stall.key) continue;
|
|
3719
|
+
const card = getTask(db, stall.id);
|
|
3720
|
+
event(db, stall.id, 'board_message', { to, review_wait: stall.key, text: `${numbered(boardKey(board.root, board.slug), card?.number, stall.id)} ${card?.title ?? ''}: ${stall.why}`,
|
|
3721
|
+
batch: { requester: 'board review waits', title: 'Review waits only an act clears', window_seconds: 0 } }, null);
|
|
3722
|
+
(report.review_waits_told ??= []).push([stall.id, stall.key]);
|
|
3723
|
+
}
|
|
3724
|
+
});
|
|
3457
3725
|
}
|
|
3458
3726
|
|
|
3459
3727
|
/**
|
|
@@ -3481,6 +3749,7 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
|
|
|
3481
3749
|
for (const r of reports) {
|
|
3482
3750
|
if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
|
|
3483
3751
|
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a delivery for ${task} to ${to} waits: ${why}`);
|
|
3752
|
+
for (const [task, to] of r.notices_resumed ?? []) log('info', `board ${r.board}: notices for ${task} to ${to} resumed`);
|
|
3484
3753
|
log('info', `delivery on board ${r.board}: ${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good; took ${Object.entries(r.ms ?? {}).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
|
3485
3754
|
}
|
|
3486
3755
|
}, (error) => log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
|
|
@@ -3517,6 +3786,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
3517
3786
|
const boards = (slugs ?? listBoards(root)).filter((slug) => !controller || dispatchingOf(root, slug).on);
|
|
3518
3787
|
if (!boards.length) return [];
|
|
3519
3788
|
const fleet = await observeFleet();
|
|
3789
|
+
fleet.noticeAlive = await noticeSessions();
|
|
3520
3790
|
const out = [];
|
|
3521
3791
|
for (const slug of boards) {
|
|
3522
3792
|
if (controller && !dispatchingOf(root, slug).on) continue;
|