@volter/supercode-orchestrator 0.5.227 → 0.5.229
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 +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/dispatch.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { registerWorkOperations, measureWork, stopWorkChild } from "@volter/supercode-harness-sdk/work-ledger";
|
|
2
|
+
registerWorkOperations([{"kind":"pass","operation":"board.round"}]);
|
|
1
3
|
// The board's dispatcher (docs/architecture/orchestrator.md §2.9): one tick reads the fleet through supercode,
|
|
2
4
|
// supervises every running card, promotes cards whose parents are done, starts what capacity allows, and tells
|
|
3
5
|
// subscribers what happened. Hermes's dispatcher is the behaviour it keeps parity with (claims, dependencies,
|
|
@@ -22,49 +24,54 @@ import { machineCall } from './doors.mjs';
|
|
|
22
24
|
import { detachedCard, detachedSession, personCustody, personOf, sessionOperation, takeSessionCustody } from './custody.mjs';
|
|
23
25
|
import { processSession } from './caller-session.mjs';
|
|
24
26
|
import { callerOf, current, isCallersCall, ownCall } from './call.mjs';
|
|
25
|
-
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
27
|
+
import { noteStep, recordSlow, timed } from '@volter/supercode-harness-sdk/slow-log';
|
|
26
28
|
import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
27
|
-
import { readMachineFleet } from './machine-fleet.mjs';
|
|
29
|
+
import { heldPanes, readMachineFleet } from './machine-fleet.mjs';
|
|
30
|
+
import { currentRecordChanges } from './current-records.mjs';
|
|
31
|
+
import { listRevision } from './listing.mjs';
|
|
28
32
|
import { homeFleetMachines } from './fleet-targets.mjs';
|
|
29
33
|
import { delegatedWork, idleInterval, questionText, resumedWatch } from './session-work.mjs';
|
|
30
|
-
import { localSessionDelegations } from '../agents.mjs';
|
|
31
|
-
import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
|
|
34
|
+
import { cardRoleAssignment, localSessionDelegations } from '../agents.mjs';
|
|
35
|
+
import { cardSectionHeading, closeKeptReviewer, creatorOf, queueCleanup, sessionProgress } from './workflow.mjs';
|
|
32
36
|
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
33
37
|
let fleetRoot = null;
|
|
34
|
-
import { inspectCompletionEvidence, returnedReview, noCodeChange, openReviewPullRequests, reviewDue, settledReviewDue, prReviewedHead, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
38
|
+
import { discoverPullRequest, discoverQueuedPullRequest, openListObservedAt, redeclaredSince, inspectCompletionEvidence, returnedReview, noCodeChange, openReviewPullRequests, reviewDue, settledReviewDue, prReviewedHead, prDisposition, prIdentity, withCompletionEvidence, withFreshReviewReads, reviewCandidate, pendingReviewRequests, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
35
39
|
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
36
|
-
import { ACTIVE_RUN_IDS, latestEvent,
|
|
40
|
+
import { ACTIVE_RUN_IDS, dispatchWait, latestEvent, latestEventIdSql } from './queries.mjs';
|
|
37
41
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
38
|
-
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
42
|
+
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, pendingUsageOrgs, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
39
43
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
44
|
+
import { establishedSlot, replacementFor, issueReplacement, requireReplacementTeardown, continuationRefusal, closeRefused, slotGeneration } from './session-continuity.mjs';
|
|
45
|
+
import { resolveAgentProfile } from '../agent-profiles.mjs';
|
|
40
46
|
import { materializeAgentHome } from '../agent-home.mjs';
|
|
41
47
|
import { installedAgentRecords } from '../installed-apps.mjs';
|
|
42
48
|
import { memorySnapshot } from '../hermes-cli.mjs';
|
|
43
|
-
import { hermesModel, codexPaneRoute } from '../worker-route.mjs';
|
|
49
|
+
import { hermesModel, codexPaneRoute, codexPaneRouteArguments } from '../worker-route.mjs';
|
|
44
50
|
import { refreshVault } from '../orchestration.mjs';
|
|
45
|
-
import { projectCodexPaneResume } from '../codex-pane-resume.mjs';
|
|
46
51
|
import { codexPaneInputs } from './codex-pane-inputs.mjs';
|
|
47
52
|
import { declareWorkerHome } from '../activation.mjs';
|
|
48
53
|
import { MODEL_ARGUMENTS, NAME_ARGUMENTS } from '../activation.mjs';
|
|
49
54
|
import { createRequire } from 'node:module';
|
|
50
55
|
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
51
|
-
import { closeSync, existsSync, mkdirSync, openSync,
|
|
56
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
52
57
|
import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
53
|
-
import { basename, dirname, join } from 'node:path';
|
|
58
|
+
import { basename, dirname, join, win32 } from 'node:path';
|
|
54
59
|
import { createHash, randomUUID } from 'node:crypto';
|
|
55
60
|
import { fileURLToPath } from 'node:url';
|
|
56
61
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
57
|
-
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
58
|
-
import { doors, fire, ownCoder, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
59
|
-
import { branchClaim, checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace, workspaceTask } from './workspace.mjs';
|
|
62
|
+
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, CLOSE_OPEN_RUNS, runField, readableIdOf, briefPath, actorOf, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
63
|
+
import { doors, fire, ownCoder, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedAs, storedStatus, templateValue, workflowFor, settleExpiringFlags, nextFlagExpiry, ownerRule, requestDetail } from './workflow.mjs';
|
|
64
|
+
import { branchClaim, checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace, reviewWorkspaces, workspaceTask } from './workspace.mjs';
|
|
60
65
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
61
66
|
import { evaluate, holds, render } from './expr.mjs';
|
|
62
67
|
import { cardName, cardNumber, identifyNotice, noticeName, numbered } from './notices.mjs';
|
|
63
68
|
import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
|
|
64
|
-
import { pendingRequestMail } from './request-mail.mjs';
|
|
69
|
+
import { pendingRequestMail, pendingRequestRows } from './request-mail.mjs';
|
|
70
|
+
import { deferredCloseCurrent, requireDeferredClose } from './close-current.mjs';
|
|
71
|
+
import { currentAgentWork, latestAgentRow } from './agent-current.mjs';
|
|
65
72
|
import {
|
|
66
73
|
cardContext, cardLabel, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
|
|
67
|
-
recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions, mentioned,
|
|
74
|
+
recomputeReady, runEffects, queueEffects, requestReview, acceptRequest, routeRequest, completeBacklogRequests, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions, mentioned,
|
|
68
75
|
} from './engine.mjs';
|
|
69
76
|
|
|
70
77
|
const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
|
|
@@ -117,7 +124,7 @@ function sc(args, { input = null, timeout = 120_000, as = null } = {}) {
|
|
|
117
124
|
let stdout = '', stderr = '', done = false;
|
|
118
125
|
const child = spawnChild(supercodeBin(), args, { cwd: tmpdir(), env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
119
126
|
const finish = (code, extra = '') => { if (done) return; done = true; clearTimeout(timer); noteStep(`supercode ${args[0]}`, performance.now() - started); resolve({ code, stdout, stderr: stderr + extra }); };
|
|
120
|
-
const timer = setTimeout(() => { child.kill('SIGKILL'); finish(null, `supercode ${args[0]} did not answer within ${timeout} ms`); }, timeout);
|
|
127
|
+
const timer = setTimeout(() => { stopWorkChild(child, () => child.kill('SIGKILL'), { signal: 'SIGKILL' }); finish(null, `supercode ${args[0]} did not answer within ${timeout} ms`); }, timeout);
|
|
121
128
|
child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
|
|
122
129
|
child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });
|
|
123
130
|
child.on('error', (error) => finish(null, error.message));
|
|
@@ -159,7 +166,8 @@ export function callerSession() {
|
|
|
159
166
|
/** The fleet as the home's machines answer for themselves (machine-fleet.mjs): this machine, and every machine an open
|
|
160
167
|
* card of the home names or one of its notice subscribers is on. */
|
|
161
168
|
export async function observeFleet(root = fleetRoot) {
|
|
162
|
-
|
|
169
|
+
// An unread or moved target set leaves presence unknown for the round, never the previous round's reading.
|
|
170
|
+
const machines = await homeFleetMachines(root, normalMachine).catch((error) => { presenceReading = null; notePresence(null); throw error; });
|
|
163
171
|
const fleet = await readMachineFleet(machines, localMachine());
|
|
164
172
|
presenceReading = { known: fleet.presenceKnown === true, online: new Set([...(fleet.unknown?.has(localMachine()) ? [] : [localMachine()]), ...fleet.online]), offline: fleet.offline ?? new Set(), unknown: fleet.unknown ?? new Set(), errors: fleet.errors ?? new Map() };
|
|
165
173
|
notePresence(presenceReading);
|
|
@@ -168,7 +176,7 @@ export async function observeFleet(root = fleetRoot) {
|
|
|
168
176
|
|
|
169
177
|
// how the launcher opens each harness a pane lane can run, and how it names the session: Claude Code is told the id
|
|
170
178
|
// it will have (`--session-id`) and resumed by it (`--resume`); Codex names its own session, which the board reads from
|
|
171
|
-
//
|
|
179
|
+
// its keyed native launch receipt, and is resumed by it (`codex resume <id>`)
|
|
172
180
|
const HARNESSES = {
|
|
173
181
|
'claude-code': { program: 'claude', fresh: (sid) => ['--session-id', sid], resume: (sid) => ['--resume', sid], namesItself: false },
|
|
174
182
|
codex: { program: 'codex', fresh: () => [], resume: (sid) => ['resume', sid], namesItself: true },
|
|
@@ -180,18 +188,43 @@ const HARNESSES = {
|
|
|
180
188
|
// machine_offline; a stream that timed out, 504), says nothing about what it was asked: it is tried again next round.
|
|
181
189
|
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);
|
|
182
190
|
const adoptedOnly = (s) => s?.mode === 'adopt' && !s.launch_key && s.surface !== 'headless';
|
|
183
|
-
/** A close the machine refused for good (not deferred): the board will not try it again, and the session runs on. */
|
|
184
|
-
const closeRefused = (s) => Boolean(s?.close_failed_at && s.close_error && !/^close deferred/.test(s.close_error) && !s.close_confirmed_at);
|
|
185
191
|
const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.machine}:${s.harness ?? 'claude-code'}:${s.session_id}` : null);
|
|
186
192
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
193
|
+
// Conversation presence requires the recorded physical store. A fleet row or scoped native activity identifies it;
|
|
194
|
+
// a pane's absence identifies an ended instance, while its existence alone says nothing about its current session.
|
|
195
|
+
// A headless worker is also read by its owned pid. Unread provenance or activity stays unknown.
|
|
190
196
|
// A pane is read from the machine's record when the fleet reading holds its panes (current, or the machine is not in
|
|
191
197
|
// it): the pane list asked through its door serves READING_MS, so a pane that ended since that reading was read live
|
|
192
198
|
// for up to those 15 s more, and a session's loss was read that much late.
|
|
193
199
|
const paneInRecord = (fleet, session) => session?.surface !== 'headless' && session?.pane && session.machine && fleet?.panes?.has(normalMachine(session.machine)) ? fleet.panes.get(normalMachine(session.machine)).has(session.pane) : null;
|
|
194
|
-
|
|
200
|
+
/** Fleet rows of this retained conversation, including its physical native store. */
|
|
201
|
+
const fleetSessionRows = (fleet, session) => {
|
|
202
|
+
const homes = nativeHome(session)?.homes;
|
|
203
|
+
return homes ? (fleet?.sessions ?? []).filter(row => row.address === addressOf(session) && inNativeHome(row, homes)) : [];
|
|
204
|
+
};
|
|
205
|
+
const fleetSessionRow = (fleet, session) => {
|
|
206
|
+
const rows = fleetSessionRows(fleet, session);
|
|
207
|
+
return rows.length === 1 ? rows[0] : rows.find(row => row.pane === session?.pane) ?? null;
|
|
208
|
+
};
|
|
209
|
+
const homeIdentity = (session) => {
|
|
210
|
+
const homes = nativeHome(session)?.homes;
|
|
211
|
+
return homes ? JSON.stringify(Object.entries(homes).sort(([a], [b]) => a.localeCompare(b))) : null;
|
|
212
|
+
};
|
|
213
|
+
const sameNativeSession = (left, right) => Boolean(addressOf(left) && addressOf(left) === addressOf(right) && homeIdentity(left) && homeIdentity(left) === homeIdentity(right));
|
|
214
|
+
function sessionAlive(fleet, session) {
|
|
215
|
+
if (!session) return null;
|
|
216
|
+
if (session.surface === 'headless' && session.pid) return pidAlive(session.pid);
|
|
217
|
+
if (session.session_id && !nativeHome(session)) return null;
|
|
218
|
+
const published = fleetSessionRows(fleet, session);
|
|
219
|
+
if (published.some(row => row.state === 'running')) return true;
|
|
220
|
+
const native = unpublishedAlive(session);
|
|
221
|
+
if (native === true) return true;
|
|
222
|
+
if (published.length) return null;
|
|
223
|
+
if (native != null) return native;
|
|
224
|
+
const pane = paneInRecord(fleet, session);
|
|
225
|
+
// A live pane alone cannot identify the conversation now running in it.
|
|
226
|
+
return !session.session_id || pane === false ? pane : null;
|
|
227
|
+
}
|
|
195
228
|
/** A subscriber's address as the session it names, for sessionAlive (its machine, harness and native id). */
|
|
196
229
|
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; };
|
|
197
230
|
/** An agent's address (`sc:<machine>:agent:<name>`) as its machine and name, or null. */
|
|
@@ -231,45 +264,38 @@ async function subscriberAlive(fleet, address) {
|
|
|
231
264
|
if (!session) return false;
|
|
232
265
|
await readBefore(fleet, session);
|
|
233
266
|
const alive = sessionAlive(fleet, session);
|
|
234
|
-
if (alive == null && subscriberGone(fleet, address)) return false;
|
|
235
267
|
return alive ?? null;
|
|
236
268
|
}
|
|
237
|
-
/**
|
|
238
|
-
* A subscriber that is gone: no transcript, no process and no pane. Its machine's discovery answered whole (no source
|
|
239
|
-
* failed, nothing truncated) without the session, the fleet lists no pane for it, and the reading came from a daemon
|
|
240
|
-
* whose index names a Claude subagent by its own file (#1423, marked `subagent_rows: 'own'` on its discovery): an older
|
|
241
|
-
* daemon's index dropped a parent session that ran a subagent, so its absence there is never read as gone.
|
|
242
|
-
*/
|
|
243
|
-
function subscriberGone(fleet, address) {
|
|
244
|
-
if (fleet.noticeAlive?.has(address) || fleet.alive.has(address) || fleet.native?.has(address)) return false;
|
|
245
|
-
const session = sessionAt(address);
|
|
246
|
-
const seen = session && discoveries.get(nativeKey(session.machine, session.harness, session.session_id));
|
|
247
|
-
if (!seen?.complete) return false;
|
|
248
|
-
return !seen.sessions.some((r) => r.locator?.session_id === session.session_id && r.locator?.harness === session.harness);
|
|
249
|
-
}
|
|
250
269
|
const paneLists = new Map();
|
|
251
270
|
/** A session the fleet reading did not publish: alive by what was read of it before (readBefore), else unknown (null). */
|
|
252
271
|
function unpublishedAlive(session) {
|
|
253
272
|
if (!session) return null;
|
|
254
273
|
if (session.surface === 'headless' && session.pid) return pidAlive(session.pid);
|
|
255
274
|
if (!session.machine) return null;
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
275
|
+
if (session.session_id) try {
|
|
276
|
+
const reading = nativeReading(nativeSessions(session), session).alive;
|
|
277
|
+
if (reading != null) return reading;
|
|
278
|
+
} catch { /* unread native identity */ }
|
|
279
|
+
if (!session.pane) return null;
|
|
260
280
|
const entry = paneLists.get(session.machine);
|
|
261
281
|
if (!entry || Date.now() - entry.at > READING_MS) return null;
|
|
262
|
-
|
|
282
|
+
if (!entry.panes) return null;
|
|
283
|
+
const present = entry.panes.some((p) => p.pane === session.pane && !p.dead);
|
|
284
|
+
return !session.session_id || !present ? present : null;
|
|
263
285
|
}
|
|
264
|
-
/**
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
286
|
+
/**
|
|
287
|
+
* A machine's panes as panes.list rows (`list`, null when unread, with `error` saying why): its held record's, read at
|
|
288
|
+
* this moment where that record is current (`held`: its rows and the session under each live pane, no door call), else
|
|
289
|
+
* its door's, as before (a record not current is no reading of the machine, and its door still answers, so a close,
|
|
290
|
+
* launch or adoption there acts as it did). A list asked as the board (`options.as`: a close) is always its door's, which
|
|
291
|
+
* lists only the panes the board's grants there let it observe.
|
|
292
|
+
*/
|
|
293
|
+
async function paneRows(machine, options = { timeout: 60_000 }) {
|
|
294
|
+
const held = options.as ? null : heldPanes(normalMachine(machine ?? localMachine()));
|
|
295
|
+
if (held) return { list: held.panes, held };
|
|
296
|
+
const r = await doorOn(machine, 'harness.v1.panes.list', {}, options);
|
|
297
|
+
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
298
|
+
return { list: Array.isArray(list) ? list : null, held: null, error: (r.stderr || r.stdout).trim().slice(-300) };
|
|
273
299
|
}
|
|
274
300
|
|
|
275
301
|
/** Native activity comes from the machine's harness discovery, not catalogue age: each session the round reads is
|
|
@@ -278,7 +304,7 @@ async function readPaneList(machine) {
|
|
|
278
304
|
const discoveries = new Map();
|
|
279
305
|
// A machine's pane list and harness discovery are read before a board write opens, never inside one: a door that
|
|
280
306
|
// takes seconds would hold the board's write lock past a worker's own command's busy wait. Reading is async (readNative,
|
|
281
|
-
//
|
|
307
|
+
// paneRows, readBefore: the doors in this process, never holding the event loop); every decision reads what was
|
|
282
308
|
// read (nativeSessions, unpublishedAlive, sessionAlive), and a reading not taken yet is unknown. One reading serves
|
|
283
309
|
// READING_MS.
|
|
284
310
|
const READING_MS = 15_000;
|
|
@@ -286,60 +312,99 @@ const txRead = (board, fn) => tx(board, fn);
|
|
|
286
312
|
/** Take the readings a decision or a board write about `session` will need, before it. */
|
|
287
313
|
async function readBefore(fleet, session) {
|
|
288
314
|
if (!session?.machine || session.surface === 'headless') return;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
315
|
+
const homes = (await readNativeHome(session))?.homes;
|
|
316
|
+
if (session.session_id && homes) {
|
|
317
|
+
const harness = session.harness ?? 'claude-code', key = nativeKey(session.machine, harness, session.session_id, homes);
|
|
318
|
+
const held = discoveries.has(key) && discoveries.get(key).activity !== false ? null : heldNative(fleet, session.machine, harness, session.session_id, homes);
|
|
319
|
+
if (held) discoveries.set(key, { at: Date.now(), sessions: [held] });
|
|
320
|
+
else try { await readNative(session.machine, harness, session.session_id, homes); } catch { /* unread: unknown */ }
|
|
321
|
+
}
|
|
322
|
+
// a pane the round's fleet reading holds no session for, on a machine whose record that reading did not hold: its
|
|
323
|
+
// machine's pane list (one reading serves READING_MS), for unpublishedAlive
|
|
324
|
+
if (session.pane && paneInRecord(fleet, session) == null) {
|
|
325
|
+
const entry = paneLists.get(session.machine);
|
|
326
|
+
if (!entry || Date.now() - entry.at > READING_MS) paneLists.set(session.machine, { at: Date.now(), panes: (await paneRows(session.machine)).list });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* A session's discovery row as its machine's record holds it (the fleet reading's `held`: the activity its machine
|
|
331
|
+
* follows of each live session, its locator, folder and last write), or null where the record holds none (a session
|
|
332
|
+
* not live there, one past the sessions its machine follows, a record not current): that one is discovered, as before.
|
|
333
|
+
* A held row says nothing of another session's absence.
|
|
334
|
+
* Codex activity is read through its own scoped discovery, including its app-server status.
|
|
335
|
+
*/
|
|
336
|
+
function heldNative(fleet, machine, harness, sessionId, homes) {
|
|
337
|
+
if (harness === 'codex') return null;
|
|
338
|
+
return fleet?.held?.get(`sc:${normalMachine(machine)}:${harness}:${sessionId}`)?.find(row =>
|
|
339
|
+
row.locator?.harness === harness && row.locator.session_id === sessionId && inNativeHome(row, homes)) ?? null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Retained launch provenance, including the original Codex run contract. */
|
|
343
|
+
const legacyNativeHomes = new Map();
|
|
344
|
+
const legacyHomeKey = session => JSON.stringify([normalMachine(session.machine), session.session_id ?? session.launch_key, session.codex_home]);
|
|
345
|
+
function nativeHome(session) {
|
|
346
|
+
if (session?.native_home) return session.native_home;
|
|
347
|
+
if (session?.codex_home && (session.harness ?? 'claude-code') === 'codex')
|
|
348
|
+
return legacyNativeHomes.get(legacyHomeKey(session)) ?? null;
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
/** The machine's file door resolves an existing legacy store, including aliases, without reading its transcript. */
|
|
352
|
+
async function readNativeHome(session) {
|
|
353
|
+
const held = nativeHome(session);
|
|
354
|
+
if (held || !session?.codex_home || session.harness !== 'codex' || !session.machine) return held;
|
|
355
|
+
const path = (session.codex_home.includes('\\') ? win32.join : join)(session.codex_home, 'sessions');
|
|
356
|
+
const answer = await doorOn(session.machine, 'files.stat', { path }, { timeout: NATIVE_DISCOVERY_MS });
|
|
357
|
+
const source = answer.code === 0 ? lastJson(answer.stdout) : null;
|
|
358
|
+
if (source?.kind !== 'directory' || typeof source.path !== 'string') return null;
|
|
359
|
+
const home = { homes: { codex: source.path }, env: { CODEX_HOME: session.codex_home } };
|
|
360
|
+
legacyNativeHomes.set(legacyHomeKey(session), home);
|
|
361
|
+
return home;
|
|
362
|
+
}
|
|
363
|
+
function nativeHomes(session) {
|
|
364
|
+
const home = nativeHome(session);
|
|
365
|
+
if (!home) throw unavailable(`native home is unknown for ${addressOf(session)}; no default home was read`);
|
|
366
|
+
return home.homes;
|
|
367
|
+
}
|
|
368
|
+
function inNativeHome(row, homes) {
|
|
369
|
+
const path = row?.locator?.storage?.path;
|
|
370
|
+
return Boolean(homes && typeof path === 'string' && Object.values(homes).some(root => path === root || path.startsWith(root + '/') || path.startsWith(root + '\\')));
|
|
371
|
+
}
|
|
372
|
+
function locatorHome(locator) {
|
|
373
|
+
if (locator?.storage?.kind !== 'file' || !['claude-code', 'codex'].includes(locator.harness)) return null;
|
|
374
|
+
const codex = locator.harness === 'codex', folder = codex ? 'sessions' : 'projects';
|
|
375
|
+
const match = (codex ? /^(.*[\\/])sessions[\\/]/ : /^(.*[\\/])projects[\\/]/).exec(locator.storage.path);
|
|
376
|
+
if (!match) return null;
|
|
377
|
+
return { homes: { [codex ? 'codex' : 'claude_code']: match[1] + folder },
|
|
378
|
+
env: { [codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR']: match[1].slice(0, -1) } };
|
|
296
379
|
}
|
|
297
380
|
/** A session's native discovery as this round read it (readNative); throws (unavailable) when it was not read. */
|
|
298
|
-
function nativeSessions(
|
|
299
|
-
const seen = discoveries.get(nativeKey(machine, harness,
|
|
381
|
+
function nativeSessions(session) {
|
|
382
|
+
const seen = discoveries.get(nativeKey(session.machine, session.harness ?? 'claude-code', session.session_id, nativeHomes(session)));
|
|
300
383
|
if (seen) return seen.sessions;
|
|
301
384
|
throw unavailable('native liveness is read before it is used (readBefore), and was not');
|
|
302
385
|
}
|
|
303
|
-
/** Read a session's native discovery through its machine's door, once a round.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
386
|
+
/** Read a session's native discovery through its machine's door, once a round. `activity: false`: only its rows (its
|
|
387
|
+
* locator, folder, whether its machine's index holds it), read from the index with no activity read for them; a
|
|
388
|
+
* reading kept without activity is read again, with it, by the next caller that needs its activity. */
|
|
389
|
+
async function readNative(machine, harness, sessionId, homes = null, { activity = true } = {}) {
|
|
390
|
+
const key = nativeKey(machine, harness, sessionId, homes), seen = discoveries.get(key);
|
|
391
|
+
if (seen && (seen.activity !== false || !activity)) return seen.sessions;
|
|
392
|
+
const { sessions } = await discoverNative(machine, harness, sessionId, homes, { activity });
|
|
393
|
+
discoveries.set(key, { at: Date.now(), sessions, ...(activity ? {} : { activity: false }) });
|
|
310
394
|
return sessions;
|
|
311
395
|
}
|
|
312
396
|
// A native discovery is bounded as the fleet reading is: one that does not answer in time is unknown, read next round.
|
|
313
397
|
const NATIVE_DISCOVERY_MS = 15_000;
|
|
314
|
-
const nativeKey = (machine, harness, sessionId) =>
|
|
315
|
-
async function discoverNative(machine, harness, sessionId,
|
|
398
|
+
const nativeKey = (machine, harness, sessionId, homes = null) => JSON.stringify([normalMachine(machine), harness, sessionId, homes]);
|
|
399
|
+
async function discoverNative(machine, harness, sessionId, homes = null, { activity = true } = {}) {
|
|
316
400
|
if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
|
|
317
401
|
// the machine's own harness door, this one's or another's, as every other harness read here is
|
|
318
402
|
// Liveness uses the native activity receipt, never transcript previews. A name miss can match many retained
|
|
319
403
|
// rows; opening every one for its messages delays the board's liveness decision for data it never reads.
|
|
320
|
-
const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000, include_latest_candidates: false, ...(
|
|
404
|
+
const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000, include_child_sessions: true, include_latest_candidates: false, ...(activity ? {} : { include_activity: false }), ...(homes ? { homes } : {}) }, { timeout: NATIVE_DISCOVERY_MS });
|
|
321
405
|
const page = result.code === 0 ? lastJson(result.stdout) : null;
|
|
322
406
|
if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
323
|
-
|
|
324
|
-
// session's own row (subscriberGone); a source still being read answers from the rows it held before
|
|
325
|
-
const complete = page.subagent_rows === 'own' && !(Array.isArray(page.failed) && page.failed.length) && !page.reconciling?.length
|
|
326
|
-
&& !page.receipt?.truncated && page.next_cursor == null;
|
|
327
|
-
return { sessions: page.sessions, complete };
|
|
328
|
-
}
|
|
329
|
-
/**
|
|
330
|
-
* Whether `session`'s machine holds no session of that id at all: its discovery was read whole (every source, nothing
|
|
331
|
-
* truncated) and has no row for it. A launch that stopped before its first turn (a question on its screen that no
|
|
332
|
-
* input answers) leaves a pane and no transcript, and a harness asked to resume that id exits at once. A reading not
|
|
333
|
-
* taken, or not whole, says nothing.
|
|
334
|
-
*/
|
|
335
|
-
async function nativeAbsent(session) {
|
|
336
|
-
if (!session?.session_id || !session.machine) return false;
|
|
337
|
-
const harness = session.harness ?? 'claude-code';
|
|
338
|
-
try {
|
|
339
|
-
const rows = await readNative(session.machine, harness, session.session_id, session.codex_home ?? null);
|
|
340
|
-
return discoveries.get(nativeKey(session.machine, harness, session.session_id))?.complete === true
|
|
341
|
-
&& !rows.some((r) => r.locator?.session_id === session.session_id);
|
|
342
|
-
} catch { return false; }
|
|
407
|
+
return { sessions: homes ? page.sessions.filter(row => inNativeHome(row, homes)) : page.sessions };
|
|
343
408
|
}
|
|
344
409
|
function nativeReading(rows, session) {
|
|
345
410
|
const row = rows.find(r => r.locator?.session_id === session.session_id && r.locator?.harness === session.harness);
|
|
@@ -350,8 +415,7 @@ function nativeReading(rows, session) {
|
|
|
350
415
|
/** Match a manual continuation by the same harness identity and an actually live machine pane. */
|
|
351
416
|
function continuationPane(fleet, session, panes) {
|
|
352
417
|
if (!session?.session_id || !(panes instanceof Set)) return null;
|
|
353
|
-
const candidates = new Set((fleet
|
|
354
|
-
if (session.pane && panes.has(session.pane)) candidates.add(session.pane);
|
|
418
|
+
const candidates = new Set(fleetSessionRows(fleet, session).filter((s) => s.pane && panes.has(s.pane)).map((s) => s.pane));
|
|
355
419
|
if (candidates.size > 1) throw new Error(`session ${session.session_id} has multiple live panes: ${[...candidates].join(', ')}; close the extra pane before continuing`);
|
|
356
420
|
return [...candidates][0] ?? null;
|
|
357
421
|
}
|
|
@@ -359,7 +423,11 @@ function continuationPane(fleet, session, panes) {
|
|
|
359
423
|
|
|
360
424
|
function pidAlive(pid) {
|
|
361
425
|
if (!pid) return false;
|
|
362
|
-
try { process.kill(pid, 0); return true; } catch (error) {
|
|
426
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
427
|
+
if (error.code === 'ESRCH') return false;
|
|
428
|
+
if (error.code === 'EPERM') return true;
|
|
429
|
+
throw new Error(`could not read worker pid ${pid}: ${error.message}`);
|
|
430
|
+
}
|
|
363
431
|
}
|
|
364
432
|
|
|
365
433
|
// ------------------------------------------------------------------ settings
|
|
@@ -431,7 +499,7 @@ const offlineMachine = (machine) => {
|
|
|
431
499
|
// each), so a park that waited for them waited out most of the round, past the owner's 8 s wait for 'parked' (VO-3578
|
|
432
500
|
// r2454 F11: 13.6 s and 13.0 s of held card verbs). A door that acts (a pane prepared, a session owned) is never cut: it
|
|
433
501
|
// finishes and the park waits for it.
|
|
434
|
-
const READ_DOORS = new Set(['harness.v1.agent.show', 'harness.v1.launches.get', 'harness.v1.panes.capture', 'harness.v1.panes.list',
|
|
502
|
+
const READ_DOORS = new Set(['files.stat', 'harness.v1.agent.show', 'harness.v1.launches.get', 'harness.v1.panes.capture', 'harness.v1.panes.list', 'harness.v1.transcripts.models',
|
|
435
503
|
'harness.v1.panes.presence', 'harness.v1.sessions.activity_under', 'harness.v1.sessions.discover', 'harness.v1.sessions.load']);
|
|
436
504
|
const refusal = (code, message) => ({ code: 1, stdout: '', stderr: JSON.stringify({ code, message }) });
|
|
437
505
|
/** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
|
|
@@ -511,25 +579,84 @@ async function recordBounces(board, fleet, entry, report) {
|
|
|
511
579
|
}
|
|
512
580
|
}
|
|
513
581
|
} catch (error) { report.undelivered?.push(['mail', 'bounces', String(error.message).slice(-200)]); }
|
|
514
|
-
//
|
|
515
|
-
|
|
582
|
+
// The first input of each session launched within its launch window, until its machine's launch record settles it.
|
|
583
|
+
// A brief is owed until its session receives it: a first input that bounced is filed to that same session again,
|
|
584
|
+
// as its run's instruction, on this round; one still undelivered when the window ends is the launch's incident.
|
|
585
|
+
const { launchWindow } = boardSettings(entry.params);
|
|
586
|
+
const running = view(board, (db) => db.prepare('SELECT t.id, r.id run_id, r.task_id, r.ended_at, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id WHERE r.ended_at IS NULL').all());
|
|
516
587
|
for (const row of running) {
|
|
517
588
|
const session = json(row.metadata, {})?.supercode;
|
|
518
|
-
if (!session?.launch_key || session.first_input || !fleet?.ok || now() - Number(session.launched_at ?? 0) >
|
|
589
|
+
if (!session?.launch_key || session.first_input || !fleet?.ok || now() - Number(session.launched_at ?? 0) > launchWindow) continue;
|
|
519
590
|
if (!fleet.capabilities?.get(normalMachine(session.machine ?? localMachine()))?.launches) continue;
|
|
520
591
|
let receipt; try { receipt = await launchReceipt(board, session); } catch { continue; }
|
|
521
592
|
const settled = receipt?.inputBounced ? 'bounced' : receipt?.inputDelivered === true ? 'delivered' : receipt?.inputRefused || receipt?.inputError ? 'undelivered' : null;
|
|
522
593
|
if (!settled) continue;
|
|
594
|
+
if (settled === 'bounced' && session.launch_pending) {
|
|
595
|
+
const refiled = bindLaunch(board, { id: row.run_id, task_id: row.id, ended_at: row.ended_at }, session, receipt, { refile: (db, address) => {
|
|
596
|
+
const task = getTask(db, row.id), told = roleBrief(db, board, entry, task, statusOf(db, task));
|
|
597
|
+
if (!told) return;
|
|
598
|
+
const brief = queueBrief(db, board, task, row.run_id, address, told, 'started');
|
|
599
|
+
setRunSession(db, row.run_id, { first_input: 'refiled', brief_owed: brief });
|
|
600
|
+
event(db, row.id, 'first_input_refiled', { launch_key: session.launch_key, pane: session.pane ?? null, address, reason: receipt.inputError ?? null }, row.run_id);
|
|
601
|
+
} });
|
|
602
|
+
if (refiled) { (report.bounced ??= []).push([row.id, session.pane ?? session.launch_key, 'first input filed again']); continue; }
|
|
603
|
+
}
|
|
523
604
|
tx(board, (db) => {
|
|
524
605
|
const run = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ?').get(row.run_id);
|
|
525
606
|
if (!run || run.ended_at || json(run.metadata, {})?.supercode?.first_input) return;
|
|
526
607
|
setRunSession(db, row.run_id, { first_input: settled });
|
|
527
608
|
if (settled !== 'bounced') return;
|
|
528
609
|
event(db, row.id, 'first_input_bounced', { launch_key: session.launch_key, pane: session.pane ?? null, reason: receipt.inputError ?? null }, row.run_id);
|
|
529
|
-
tell(db, row.id, `${row.id}: the first input of the session the board launched for it${session.pane ? ` (pane ${session.pane})` : ''} bounced: ${receipt.inputError}.
|
|
610
|
+
tell(db, row.id, `${row.id}: the first input of the session the board launched for it${session.pane ? ` (pane ${session.pane})` : ''} bounced: ${receipt.inputError}. It could not be filed to its session again; its launch becomes an incident if the launch window ends with its brief undelivered.`);
|
|
530
611
|
(report.bounced ??= []).push([row.id, session.pane ?? session.launch_key, 'first input']);
|
|
531
612
|
});
|
|
532
613
|
}
|
|
614
|
+
await settleOwedBriefs(board, fleet, entry, report, launchWindow);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* A re-filed brief is owed until its session receives it (ADR 0019): the session's own turn reading (its published fleet
|
|
619
|
+
* row, else its native discovery, as turnInFlight reads it) shows a turn state that began after the brief was filed. A
|
|
620
|
+
* session that cannot be read keeps it owed. When the launch window ends, a brief never filed, or a read session with no
|
|
621
|
+
* turn since its filing, makes the launch an incident with that reading as its evidence.
|
|
622
|
+
*/
|
|
623
|
+
async function settleOwedBriefs(board, fleet, entry, report, launchWindow) {
|
|
624
|
+
const owed = view(board, db => db.prepare(`SELECT t.id, r.id run_id, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id
|
|
625
|
+
WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.first_input')='refiled'`).all());
|
|
626
|
+
for (const row of owed) {
|
|
627
|
+
await yieldToOwner();
|
|
628
|
+
if (stopping) return;
|
|
629
|
+
const session = json(row.metadata, {})?.supercode ?? {};
|
|
630
|
+
const filed = view(board, db => db.prepare(`SELECT created_at, json_extract(payload,'$.message_id') AS message FROM task_events
|
|
631
|
+
WHERE task_id=? AND run_id=? AND kind='session_notice_filed' AND json_extract(payload,'$.source_event')=? ORDER BY id DESC LIMIT 1`)
|
|
632
|
+
.get(row.id, row.run_id, session.brief_owed ?? null));
|
|
633
|
+
let reading = null;
|
|
634
|
+
if (filed && fleet?.ok && session.session_id) {
|
|
635
|
+
reading = fleetSessionRow(fleet, session);
|
|
636
|
+
if (reading?.turn == null) try {
|
|
637
|
+
await readNativeHome(session);
|
|
638
|
+
const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, nativeHomes(session)), session).row?.activity;
|
|
639
|
+
reading = activity ? { turn: activity.turn ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null, turn_source: activity.evidence?.source ?? null } : null;
|
|
640
|
+
} catch { reading = null; }
|
|
641
|
+
if (reading?.turn == null) reading = null;
|
|
642
|
+
}
|
|
643
|
+
if (reading && ['working', 'needs_input', 'idle'].includes(reading.turn) && Number(reading.turn_since_ms) > Number(filed.created_at) * 1000) {
|
|
644
|
+
tx(board, db => { if (currentRun(db, row.id)?.id === row.run_id) setRunSession(db, row.run_id, { first_input: 'delivered', brief_owed: null }); });
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (now() - Number(session.launched_at ?? 0) < launchWindow || (filed && !reading)) continue;
|
|
648
|
+
const error = filed ? `its re-filed brief ${filed.message} shows no turn in its session since it was filed when the launch window ended`
|
|
649
|
+
: 'its re-filed brief was never filed before the launch window ended';
|
|
650
|
+
const evidence = { error, brief_event: session.brief_owed ?? null, message_id: filed?.message ?? null, filed_at: filed?.created_at ?? null,
|
|
651
|
+
reading: reading ? { turn: reading.turn, turn_since_ms: reading.turn_since_ms ?? null, source: reading.turn_source ?? null } : null,
|
|
652
|
+
launch_key: session.launch_key ?? null, pane: session.pane ?? null, launch_window_seconds: launchWindow };
|
|
653
|
+
tx(board, db => {
|
|
654
|
+
if (currentRun(db, row.id)?.id !== row.run_id) return;
|
|
655
|
+
setRunSession(db, row.run_id, { first_input: 'undelivered', brief_owed: null });
|
|
656
|
+
surfaceIncident(db, board, entry, row.id, 'spawn_failed', evidence, row.run_id);
|
|
657
|
+
});
|
|
658
|
+
report.failed.push([row.id, error]);
|
|
659
|
+
}
|
|
533
660
|
}
|
|
534
661
|
|
|
535
662
|
/**
|
|
@@ -578,7 +705,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
578
705
|
const run = currentRun(board.db, id), session = run?.metadata?.supercode;
|
|
579
706
|
if (session?.harness !== 'claude-code' || session.mode !== 'fresh' || !session.launch_key || session.first_response || !session.session_id) continue;
|
|
580
707
|
let locator;
|
|
581
|
-
try { locator = nativeReading(nativeSessions(session
|
|
708
|
+
try { locator = nativeReading(nativeSessions(session), session).row?.locator; } catch { continue; }
|
|
582
709
|
if (!locator) continue;
|
|
583
710
|
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 });
|
|
584
711
|
const loaded = answer.code === 0 ? lastJson(answer.stdout) : null;
|
|
@@ -613,7 +740,8 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
613
740
|
if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
|
|
614
741
|
const runRow = currentRun(db, id);
|
|
615
742
|
let session = runRow?.metadata?.supercode ?? null;
|
|
616
|
-
const
|
|
743
|
+
const liveBindings = fleetSessionRows(fleet, session).filter(item => item.state === 'running' && item.pane);
|
|
744
|
+
const liveBinding = new Set(liveBindings.map(item => item.pane)).size === 1 ? liveBindings[0] : null;
|
|
617
745
|
if(liveBinding?.pane&&liveBinding.pane!==session.pane){
|
|
618
746
|
session={...session,pane:liveBinding.pane,missing_since:null};setRunSession(db,runRow.id,session);
|
|
619
747
|
event(db,id,'session_rebound',{session_id:session.session_id,pane:session.pane},runRow.id);
|
|
@@ -621,9 +749,9 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
621
749
|
const t = now();
|
|
622
750
|
if (runRow && session) {
|
|
623
751
|
const alive = session.surface === 'headless' ? pidAlive(session.pid) : sessionAlive(fleet, session);
|
|
624
|
-
let native = fleet
|
|
752
|
+
let native = fleetSessionRow(fleet, session);
|
|
625
753
|
if (!native && session.session_id) try {
|
|
626
|
-
const row = nativeReading(nativeSessions(session
|
|
754
|
+
const row = nativeReading(nativeSessions(session), session).row;
|
|
627
755
|
native = { turn: row?.activity?.turn };
|
|
628
756
|
} catch { /* an unread turn stays unknown */ }
|
|
629
757
|
// the session's machine as this round read it, so the card can say why a session reads unknown (executionOf)
|
|
@@ -682,7 +810,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
682
810
|
const reason = session?.launch_deferred_error ?? session?.launch_error ?? 'no session or process was bound to the dispatched run';
|
|
683
811
|
const error = `launch window ${settings.launchWindow}s expired after ${age}s: ${reason}`;
|
|
684
812
|
if (!session?.launch_expired_at) {
|
|
685
|
-
//
|
|
813
|
+
// An unbound launch retains its unknown conversation and needs deliberate disposition before replacement.
|
|
686
814
|
setRunSession(db, runRow.id, { launch_expired_at: t, launch_error: error, ...(session?.launch_key && session.mode === 'fresh' ? { forgotten: true } : {}) });
|
|
687
815
|
event(db, id, 'session_launch_expired', { error, age_seconds: age, window_seconds: settings.launchWindow, key: session?.launch_key ?? null, pane: session?.pane ?? null }, runRow.id);
|
|
688
816
|
}
|
|
@@ -744,6 +872,14 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
744
872
|
if (t - Number(session.launched_at ?? 0) < settings.crashGrace) return;
|
|
745
873
|
// how the worker ended, from the exit code its wrapper wrote (Hermes reads its reaped children's)
|
|
746
874
|
const code = session.exit_file && existsSync(session.exit_file) ? Number(readFileSync(session.exit_file, 'utf8').trim()) : null;
|
|
875
|
+
if (code === 78) {
|
|
876
|
+
const refusal = session.continuation_failure_file && existsSync(session.continuation_failure_file)
|
|
877
|
+
? readFileSync(session.continuation_failure_file, 'utf8').trim() : null;
|
|
878
|
+
const error = refusal || `headless worker ${session.pid} could not continue or retain its native conversation; its refusal is in ${join(logsDir(board.root, board.slug), `${id}.log`)}`;
|
|
879
|
+
setRunSession(db, runRow.id, { continuation_required: true });
|
|
880
|
+
surfaceIncident(db, board, entry, id, 'exited', { code, worker_pid: session.pid, error }, runRow.id);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
747
883
|
const why = code === 75 ? 'worker hit a rate limit (exit 75)' : code === 0 ? `worker ${session.pid} exited cleanly (rc=0) without completing or blocking the card` : `worker ${session.pid} exited${code === null ? '' : ` with ${code}`} without completing the card`;
|
|
748
884
|
observe('exited', { code, worker_pid: session.pid, exit_code: code, protocol_violation: code === 0, error: why }, 'crashed');
|
|
749
885
|
return;
|
|
@@ -771,9 +907,8 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
771
907
|
return;
|
|
772
908
|
} catch (error) { if (!error.noTransition) { report.failed.push([id, `session_closed: ${String(error.message).slice(-200)}`]); return; } }
|
|
773
909
|
}
|
|
774
|
-
if (exit.failed && session.mode === 'resume' && !session.seen_at)
|
|
775
|
-
setRunSession(db, runRow.id, {
|
|
776
|
-
}
|
|
910
|
+
if (exit.failed && session.mode === 'resume' && !session.seen_at)
|
|
911
|
+
setRunSession(db, runRow.id, { resume_failed_before_live: true });
|
|
777
912
|
observe('exited', ended, 'crashed');
|
|
778
913
|
return;
|
|
779
914
|
}
|
|
@@ -798,9 +933,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
798
933
|
if (alive) { next.seen_at ??= t; next.missing_since = null; }
|
|
799
934
|
if (missing) next.missing_since ??= t;
|
|
800
935
|
if (missing && (ended || t - next.missing_since >= Number(limits.grace ?? settings.grace))) {
|
|
801
|
-
//
|
|
802
|
-
// came up is not resumable.
|
|
803
|
-
if (next.mode === 'resume' && !next.seen_at) next.resumable = false;
|
|
936
|
+
// Loss, including a resume that never came up, retains the same conversation for deliberate disposition.
|
|
804
937
|
setRunSession(db, runRow.id, next);
|
|
805
938
|
observe('session_lost', { session_id: next.session_id, machine: next.machine, pane: next.pane, mode: next.mode, seen: Boolean(next.seen_at),
|
|
806
939
|
never_came_up: next.mode === 'fresh' && !next.seen_at, ...(ended ? { pane_ended: ended } : {}),
|
|
@@ -824,7 +957,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
824
957
|
/** A running card's limits: its status's `run.limits`, each evaluated for the card (seconds; null for none). */
|
|
825
958
|
function limitsOf(db, board, entry, task, session) {
|
|
826
959
|
const status = statusOf(db, task);
|
|
827
|
-
const scope = scopeOf(db, board, task, { params: entry.params, extra: { run: { surface: session?.surface ?? null, local: true } } });
|
|
960
|
+
const scope = scopeOf(db, board, task, { params: entry.params, lazy: true, extra: { run: { surface: session?.surface ?? null, local: true } } });
|
|
828
961
|
const out = {};
|
|
829
962
|
for (const [name, expr] of Object.entries({ ...entry.workflow.run?.limits, ...runSpec(entry, status, task)?.limits })) {
|
|
830
963
|
const v = evaluate(expr, scope);
|
|
@@ -833,33 +966,42 @@ function limitsOf(db, board, entry, task, session) {
|
|
|
833
966
|
return out;
|
|
834
967
|
}
|
|
835
968
|
|
|
836
|
-
async function stopSession(board, session) {
|
|
969
|
+
async function stopSession(board, session, revalidate = () => {}) {
|
|
837
970
|
if (!session) return;
|
|
838
971
|
if (view(board, db => detachedSession(db, session))) throw Object.assign(new Error('close deferred: session was detached to a person'), { sessionDetached: true });
|
|
839
|
-
return sessionOperation(board, { address: addressOf(session), kind: 'session close' }, () => stopOwnedSession(board, session));
|
|
972
|
+
return sessionOperation(board, { address: addressOf(session), kind: 'session close' }, () => stopOwnedSession(board, session, revalidate));
|
|
840
973
|
}
|
|
841
974
|
|
|
842
|
-
async function stopOwnedSession(board, session) {
|
|
975
|
+
async function stopOwnedSession(board, session, revalidate = () => {}) {
|
|
843
976
|
if (boardCopyOf(board)) throw copyRefusal(board, 'closes no session');
|
|
844
|
-
if (session.surface === 'headless'
|
|
977
|
+
if (session.surface === 'headless') {
|
|
978
|
+
if (normalMachine(session.machine ?? localMachine()) !== localMachine()) throw new Error(`close deferred: headless worker ${session.pid} is not on this machine`);
|
|
979
|
+
if (!session.pid) throw new Error('close deferred: headless launch has no confirmed worker pid');
|
|
980
|
+
if (!pidAlive(session.pid)) return;
|
|
845
981
|
// SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
|
|
846
|
-
const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
|
|
847
|
-
signal('SIGTERM');
|
|
982
|
+
const signal = (sig) => { try { stopWorkChild(session.pid, () => process.kill(-session.pid, sig), { signal: sig }); } catch { try { stopWorkChild(session.pid, () => process.kill(session.pid, sig), { signal: sig }); } catch { /* gone */ } } };
|
|
983
|
+
revalidate(); signal('SIGTERM');
|
|
848
984
|
for (let waited = 0; waited < 10 && pidAlive(session.pid); waited += 1) await new Promise((resolve) => setTimeout(resolve, 500));
|
|
849
|
-
if (pidAlive(session.pid))
|
|
985
|
+
if (pidAlive(session.pid)) {
|
|
986
|
+
revalidate(); signal('SIGKILL');
|
|
987
|
+
for (let waited = 0; waited < 10 && pidAlive(session.pid); waited += 1) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
988
|
+
}
|
|
989
|
+
if (pidAlive(session.pid)) throw new Error(`close deferred: headless worker ${session.pid} is still present after SIGKILL`);
|
|
850
990
|
return;
|
|
851
991
|
}
|
|
852
992
|
if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
|
|
853
993
|
if (!session.pane && session.surface === 'native') {
|
|
994
|
+
revalidate();
|
|
854
995
|
const r = await sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
|
|
855
996
|
if (r.code !== 0) throw new Error(`${doorTransient(r) ? 'close deferred: ' : ''}closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
856
997
|
// the close's receipt is a discovery taken after it, never the round's cached one from before
|
|
857
|
-
discoveries.delete(nativeKey(session.machine, session.harness, session.session_id));
|
|
858
|
-
const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id, session
|
|
998
|
+
discoveries.delete(nativeKey(session.machine, session.harness, session.session_id, nativeHomes(session)));
|
|
999
|
+
const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id, nativeHomes(session)), session);
|
|
859
1000
|
if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
|
|
860
1001
|
return;
|
|
861
1002
|
}
|
|
862
1003
|
if (session.pane) {
|
|
1004
|
+
revalidate();
|
|
863
1005
|
const r = await runtimeDoor(normalMachine(session.machine??localMachine())===localMachine()?null:session.machine,'harness.v1.panes.close',{pane:session.pane,...(session.launch_key?{launch:session.launch_key}:{})},{env:commandEnv(),timeout:60_000,as:boardAddress(board.label)});
|
|
864
1006
|
// a door that timed out or could not be reached says nothing about the pane: the close waits for the next round
|
|
865
1007
|
const transient = doorTransient(r);
|
|
@@ -912,77 +1054,120 @@ export function whereOf(board, task) {
|
|
|
912
1054
|
// what the platform tells expressions about this machine and this tick
|
|
913
1055
|
const hostOf = (pressure) => ({ memory_pressure: pressure, max_in_progress: derivedMaxInProgress() });
|
|
914
1056
|
|
|
915
|
-
/**
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1057
|
+
/**
|
|
1058
|
+
* What every board of the home at `root` holds now, counted for its caps (capacity.mjs). A counted session's model is
|
|
1059
|
+
* the one its transcript last names (transcriptOf), as this machine's daemon holds it: asked once a round, for the
|
|
1060
|
+
* sessions counted, through harness.v1.transcripts.models. The count is taken with the answers the last round had; where
|
|
1061
|
+
* this round's answers differ, it is taken again with them, so it always counts this round's. With `retainRevisions`,
|
|
1062
|
+
* each board read by the count returned stays open with its revision (`revisions`, closed by `releaseRevisions`), so a
|
|
1063
|
+
* start's admission can check nothing changed since.
|
|
1064
|
+
*/
|
|
1065
|
+
async function homeHoldings(root, slugs, lanes, fleet, entry, { retainRevisions = false } = {}) {
|
|
1066
|
+
const live = (s) => fleetSessionRow(fleet, s);
|
|
1067
|
+
const count = async (modelOf, read) => {
|
|
1068
|
+
const out = [];
|
|
1069
|
+
out.revisions = new Map();
|
|
1070
|
+
out.releaseRevisions = () => { for (const held of out.revisions.values()) held.board.close(); out.revisions.clear(); };
|
|
1071
|
+
for (const slug of slugs) {
|
|
929
1072
|
try {
|
|
930
|
-
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1073
|
+
// The owner holds this connection and its current-row projection. Capacity validates its
|
|
1074
|
+
// optimistic revision across yields; an unstable read still admits nothing.
|
|
1075
|
+
const b = openBoard(root, slug);
|
|
1076
|
+
try {
|
|
1077
|
+
b.slug ??= slug; b.label ??= boardLabel(root, slug);
|
|
1078
|
+
// each run's session read before it is counted live or not (unknown stays unknown)
|
|
1079
|
+
if (read) 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);
|
|
1080
|
+
const storedOf = (id, row) => {
|
|
1081
|
+
// The usual native hold mapping depends only on this card's retained columns. Custom
|
|
1082
|
+
// stored_as expressions retain their ordinary authoritative evaluation.
|
|
1083
|
+
if (entry.workflow.stored_as?.length) return storedStatus(b.db, b, entry, id).stored;
|
|
1084
|
+
if (entry.workflow.flags?.paused && !row.archived) {
|
|
1085
|
+
if (json(row.paused, null)) return 'paused';
|
|
1086
|
+
if ((json(row.blocked_by, []) ?? []).length) return 'blocked';
|
|
1087
|
+
}
|
|
1088
|
+
return entry.workflow.flags?.paused ? storedAs(entry.workflow,row.stage) : row.status;
|
|
1089
|
+
};
|
|
1090
|
+
// a session on a machine whose record is not current is not known gone: it keeps its place in the caps
|
|
1091
|
+
out.push(...await holdings(b, { lanes, fleetOk: fleet.ok,
|
|
1092
|
+
alive: (s) => sessionAlive(fleet, s) ?? (s?.surface !== 'headless' && s?.machine && fleet.unknown?.has(normalMachine(s.machine)) ? true : null), liveRecord: live, modelOf, storedOf, machineOf: normalMachine, home: localMachine(),
|
|
1093
|
+
runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
|
|
1094
|
+
if (retainRevisions) out.revisions.set(slug, { board: b, revision: currentRecordChanges(b.db).token });
|
|
1095
|
+
}
|
|
1096
|
+
finally { if (!retainRevisions || !out.revisions.has(slug)) b.close(); }
|
|
1097
|
+
} catch (error) { out.releaseRevisions(); error.capacityUnread = true; throw error; }
|
|
1098
|
+
}
|
|
1099
|
+
return out;
|
|
1100
|
+
};
|
|
1101
|
+
const asked = new Map();
|
|
1102
|
+
const transcriptKey = (t) => JSON.stringify([t.address, t.locator.storage]);
|
|
1103
|
+
const modelOf = (s) => { const t = transcriptOf(fleet, s); if (!t) return null; const key = transcriptKey(t); asked.set(key, t.locator); return transcriptModels.get(key) ?? null; };
|
|
1104
|
+
let out = await count(modelOf, true);
|
|
1105
|
+
let answered;
|
|
1106
|
+
try { answered = asked.size ? await readTranscriptModels(asked) : new Map(); }
|
|
1107
|
+
catch (error) { out.releaseRevisions(); throw error; }
|
|
1108
|
+
const changed = [...new Set([...asked.keys(), ...transcriptModels.keys()])].some((key) => (answered.get(key) ?? null) !== (transcriptModels.get(key) ?? null));
|
|
1109
|
+
transcriptModels = answered;
|
|
1110
|
+
if (changed) {
|
|
1111
|
+
// the count taken again holds its own boards' revisions, read with it
|
|
1112
|
+
out.releaseRevisions();
|
|
1113
|
+
out = await count((s) => { const t = transcriptOf(fleet, s); return t ? transcriptModels.get(transcriptKey(t)) ?? null : null; }, false);
|
|
941
1114
|
}
|
|
942
1115
|
out.defaults = harnessDefaults(out);
|
|
943
1116
|
return out;
|
|
944
1117
|
}
|
|
945
1118
|
|
|
946
|
-
|
|
947
|
-
|
|
1119
|
+
/** The models this machine's daemon last answered, by address and transcript storage (homeHoldings). */
|
|
1120
|
+
let transcriptModels = new Map();
|
|
1121
|
+
/** One ask of this machine's daemon for the model each of `asked` (identity → transcript locator) last names: a map of
|
|
1122
|
+
* the answers (a model or null); empty when the daemon did not answer, so each reads as unknown (null). */
|
|
1123
|
+
async function readTranscriptModels(asked) {
|
|
1124
|
+
const entries = [...asked];
|
|
1125
|
+
const r = await doorOn(localMachine(), 'harness.v1.transcripts.models', { locators: entries.map(([, locator]) => locator) }, { timeout: NATIVE_DISCOVERY_MS });
|
|
1126
|
+
const models = r.code === 0 ? lastJson(r.stdout)?.models : null;
|
|
1127
|
+
if (!Array.isArray(models) || models.length !== entries.length) return new Map();
|
|
1128
|
+
return new Map(entries.map(([key], index) => [key, typeof models[index] === 'string' ? models[index] : null]));
|
|
1129
|
+
}
|
|
948
1130
|
|
|
949
1131
|
/**
|
|
950
|
-
* The
|
|
951
|
-
*
|
|
952
|
-
*
|
|
1132
|
+
* The transcript a session on this machine runs in, whose model (the last one its transcript's last 512 KiB names,
|
|
1133
|
+
* interchange transcript-model.mjs) its machine's daemon holds: `{ address, locator }`, the locator from the round's
|
|
1134
|
+
* fleet reading (the record's sessions) or this round's discovery of the session, and only for the transcript the board
|
|
1135
|
+
* has always read: Claude Code's under this process's own CLAUDE_CONFIG_DIR (or ~/.claude), `projects/<folder>/<id>.jsonl`;
|
|
1136
|
+
* Codex's rollout under its own CODEX_HOME (or ~/.codex), `sessions/<year>/<month>/<day>/…-<id>.jsonl`, the home and
|
|
1137
|
+
* the transcript compared by real path (realOf). Null for another machine's session, another harness's, a transcript
|
|
1138
|
+
* elsewhere (another home, a compressed rollout), or one neither reading holds.
|
|
953
1139
|
*/
|
|
954
|
-
|
|
1140
|
+
// each path as its real path (symlinks resolved), kept once resolved: a home and a transcript are compared by where
|
|
1141
|
+
// they are, however either is spelled; a path that does not resolve stands as written, and is resolved again next time
|
|
1142
|
+
const realPaths = new Map();
|
|
1143
|
+
const realOf = (path) => {
|
|
1144
|
+
if (realPaths.has(path)) return realPaths.get(path);
|
|
1145
|
+
try { const real = realpathSync(path); realPaths.set(path, real); return real; } catch { return path; }
|
|
1146
|
+
};
|
|
1147
|
+
function transcriptOf(fleet, session) {
|
|
955
1148
|
if (!session?.session_id || normalMachine(session.machine ?? localMachine()) !== localMachine()) return null;
|
|
956
|
-
const sid = session.session_id;
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const size = statSync(file).size;
|
|
979
|
-
const fd = openSync(file, 'r');
|
|
980
|
-
const length = Math.min(size, 512 * 1024);
|
|
981
|
-
const buf = Buffer.alloc(length);
|
|
982
|
-
try { readSync(fd, buf, 0, length, size - length); } finally { closeSync(fd); }
|
|
983
|
-
const models = [...buf.toString('utf8').matchAll(/"model":"([^"]+)"/g)].map((m) => m[1]).filter((m) => !m.startsWith('<'));
|
|
984
|
-
return models.at(-1) ?? null;
|
|
985
|
-
} catch { return null; }
|
|
1149
|
+
const harness = session.harness ?? 'claude-code', sid = session.session_id;
|
|
1150
|
+
if (harness !== 'claude-code' && harness !== 'codex') return null;
|
|
1151
|
+
const homes = nativeHome(session)?.homes;
|
|
1152
|
+
if (!homes) return null;
|
|
1153
|
+
const address = `sc:${localMachine()}:${harness}:${sid}`;
|
|
1154
|
+
const named = (row) => row?.locator?.harness === harness && row.locator.session_id === sid ? row : null;
|
|
1155
|
+
const read = (row) => {
|
|
1156
|
+
const path = row?.locator?.storage?.kind === 'file' ? row.locator.storage.path : null;
|
|
1157
|
+
if (typeof path !== 'string') return null;
|
|
1158
|
+
const real = realOf(path);
|
|
1159
|
+
if (harness === 'claude-code') {
|
|
1160
|
+
const projects = realOf(homes.claude_code);
|
|
1161
|
+
if (dirname(dirname(real)) !== projects || basename(real) !== `${sid}.jsonl`) return null;
|
|
1162
|
+
} else {
|
|
1163
|
+
const root = realOf(homes.codex);
|
|
1164
|
+
if (dirname(dirname(dirname(dirname(real)))) !== root || !basename(real).endsWith(`-${sid}.jsonl`)) return null;
|
|
1165
|
+
}
|
|
1166
|
+
return { address, locator: row.locator };
|
|
1167
|
+
};
|
|
1168
|
+
// the record's row, else this round's discovery of the session
|
|
1169
|
+
return fleet?.transcripts?.get(address)?.map(named).map(read).find(Boolean)
|
|
1170
|
+
?? read(discoveries.get(nativeKey(localMachine(), harness, sid, homes))?.sessions.find(named));
|
|
986
1171
|
}
|
|
987
1172
|
|
|
988
1173
|
/**
|
|
@@ -1000,7 +1185,24 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
1000
1185
|
const pressure = memoryPressure();
|
|
1001
1186
|
const tickState = { local_started: 0 };
|
|
1002
1187
|
const startable = new Set(Object.keys(workflow.statuses).filter((status) => transitionsFor(workflow, status, d.start).length));
|
|
1003
|
-
|
|
1188
|
+
// These exclusions are unconditional below; workflow guards, priority, dependencies and
|
|
1189
|
+
// clocks still decide over the resulting current candidates on every round.
|
|
1190
|
+
const everyCard = [];
|
|
1191
|
+
let startCursor = '';
|
|
1192
|
+
for (;;) {
|
|
1193
|
+
const page = view(board, db => db.prepare(`SELECT t.id FROM supercode_cards c CROSS JOIN tasks t ON t.id=c.task_id
|
|
1194
|
+
WHERE c.subtask_of IS NULL AND c.stage NOT IN ('done','cancelled') AND t.current_run_id IS NULL
|
|
1195
|
+
AND t.status!='archived' AND t.id>?${only ? ' AND t.id=?' : ''}
|
|
1196
|
+
UNION ALL SELECT t.id FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
1197
|
+
WHERE t.current_run_id IS NULL AND t.status NOT IN ('archived','done','cancelled') AND c.stage IS NULL
|
|
1198
|
+
AND c.subtask_of IS NULL AND t.id>?${only ? ' AND t.id=?' : ''} ORDER BY id LIMIT 32`)
|
|
1199
|
+
.all(startCursor, ...(only ? [only] : []), startCursor, ...(only ? [only] : [])));
|
|
1200
|
+
if (!page.length) break;
|
|
1201
|
+
everyCard.push(...page);
|
|
1202
|
+
startCursor = page.at(-1).id;
|
|
1203
|
+
if (page.length < 32) break;
|
|
1204
|
+
await yieldToOwner();
|
|
1205
|
+
}
|
|
1004
1206
|
const scopeFor = (task, extra = {}) => scopeOf(board.db, board, task, { params, lazy: true, extra: { host: hostOf(pressure), tick: tickState, ...extra } });
|
|
1005
1207
|
// who would run it: the role of the status its start moves it to, and that role's lane
|
|
1006
1208
|
const planOf = (task, scope) => {
|
|
@@ -1017,34 +1219,41 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
1017
1219
|
report.queued.push([task.id, why]);
|
|
1018
1220
|
if (!dry && !guardNoted(board.db, task.id, why)) tx(board, db => noteGuard(db, task.id, why));
|
|
1019
1221
|
};
|
|
1020
|
-
|
|
1222
|
+
const selectionWait = (task, state) => {
|
|
1223
|
+
if (state.person) return `assigned to ${state.person}: a person's card runs no session; it closes when that person completes it`;
|
|
1224
|
+
if (state.status === 'running' && completedWork(board.db, task.id)) return 'work is complete; the board must select its independent review before another implementer run';
|
|
1225
|
+
if (state.detached) return `session belongs to ${state.detached.person}; verified adoption is required`;
|
|
1226
|
+
if (state.dispatch_hold) return 'dispatch held: an explicit start or verified adoption must release it';
|
|
1227
|
+
if (state.backlog) return `backlog: ${state.backlog.reason ?? 'not planned to start'}`;
|
|
1228
|
+
if (workflow.run && state.paused) return `paused: ${state.paused.reason ?? 'manager pause'}`;
|
|
1229
|
+
if (workflow.run && state.blocked_by.length) return `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`;
|
|
1230
|
+
if (workflow.run && state.every && state.next_run_at > now()) return `next scheduled run at ${state.next_run_at}`;
|
|
1231
|
+
if (!startable.has(state.status)) return `no ${d.start} transition from ${state.status}`;
|
|
1232
|
+
return null;
|
|
1233
|
+
};
|
|
1234
|
+
for (const { id } of everyCard) {
|
|
1235
|
+
await yieldToOwner();
|
|
1236
|
+
if (stopping) return;
|
|
1237
|
+
const task = view(board, db => getTask(db, id));
|
|
1238
|
+
if (!task) continue;
|
|
1021
1239
|
if (only && task.id !== only) continue;
|
|
1022
1240
|
// a subtask or a card with a run is never started here, whatever its scope says: no scope is built to learn it (the
|
|
1023
1241
|
// scope of each of a board's 2,155 unarchived cards, 2,039 of them subtasks or running, was 0.8 s of every round)
|
|
1024
1242
|
if (task.subtask_of || task.current_run_id) continue;
|
|
1025
1243
|
const state = scopeFor(task).card;
|
|
1026
1244
|
if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
if (state.person) { waiting(task, `assigned to ${state.person}: a person's card runs no session; it closes when that person completes it`); continue; }
|
|
1030
|
-
if (state.status === 'running' && completedWork(board.db, task.id)) {
|
|
1031
|
-
waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
|
|
1032
|
-
}
|
|
1033
|
-
if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
|
|
1034
|
-
if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
|
|
1035
|
-
if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
|
|
1036
|
-
if (workflow.run && state.blocked_by.length) { waiting(task, `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`); continue; }
|
|
1037
|
-
if (workflow.run && state.every && state.next_run_at > now()) { waiting(task, `next scheduled run at ${state.next_run_at}`); continue; }
|
|
1038
|
-
if (!startable.has(state.status)) { waiting(task, `no ${d.start} transition from ${state.status}`); continue; }
|
|
1245
|
+
const selectionReason = selectionWait(task, state);
|
|
1246
|
+
if (selectionReason) { waiting(task, selectionReason); continue; }
|
|
1039
1247
|
const plan = planOf(task, scopeFor(task));
|
|
1040
1248
|
if (!plan) { waiting(task, `start guard refused: ${transitionsFor(workflow, state.status, d.start).map(t => t.guard ?? 'sender role').join(' | ')}`); continue; }
|
|
1041
1249
|
const scope = scopeFor(task, { lane: plan.lane ?? {}, run: { surface: plan.lane?.surface ?? null, local: executionHost(plan.lane, task.machine, localMachine(), normalMachine) === localMachine() } });
|
|
1042
1250
|
if (!holds(d.eligible ?? null, scope)) { waiting(task, `dispatch eligibility refused (memory ${pressure}, local starts ${tickState.local_started}): ${d.eligible}`); continue; }
|
|
1043
|
-
candidates.push({ task, plan,
|
|
1251
|
+
candidates.push({ task, plan, status: state.status,
|
|
1252
|
+
slot: String(templateValue(plan.runSpec?.session ?? 'worker', scope) ?? 'worker'),
|
|
1253
|
+
order: (d.order ?? []).map((o) => { const desc = o.startsWith('-'); return { v: evaluate(desc ? o.slice(1) : o, scope), desc }; }) });
|
|
1044
1254
|
}
|
|
1045
|
-
const keyOf = (c) => (d.order ?? []).map((o) => { const desc = o.startsWith('-'); const v = evaluate(desc ? o.slice(1) : o, c.scope); return { v, desc }; });
|
|
1046
1255
|
candidates.sort((a, b) => {
|
|
1047
|
-
const ka =
|
|
1256
|
+
const ka = a.order; const kb = b.order;
|
|
1048
1257
|
for (let i = 0; i < ka.length; i += 1) {
|
|
1049
1258
|
if (ka[i].v === kb[i].v) continue;
|
|
1050
1259
|
const lt = (ka[i].v ?? -Infinity) < (kb[i].v ?? -Infinity);
|
|
@@ -1055,16 +1264,23 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
1055
1264
|
// the caps, over what the home holds now; what each candidate would be once started
|
|
1056
1265
|
const caps = capacity(d.capacity, held, evaluate);
|
|
1057
1266
|
const entryOf = (c) => ({ card: c.task.id, board: board.slug, machine: executionHost(c.plan.lane, c.task.machine, localMachine(), normalMachine), assignee: c.plan.profile ?? null,
|
|
1058
|
-
dispatch: true, slot:
|
|
1267
|
+
dispatch: true, slot: c.slot,
|
|
1059
1268
|
status: c.plan.target, stored: c.plan.target, model: c.task.model_override ?? c.plan.lane?.model ?? held.defaults?.[c.plan.lane?.harness] ?? null, harness: c.plan.lane?.harness ?? null });
|
|
1060
1269
|
const waitingEntries = candidates.map(entryOf);
|
|
1061
1270
|
// a dry run (dispatch --dry-run) plans the same way and writes nothing: no notes, no moves, no launches
|
|
1062
1271
|
const note = (id, why) => { if (!dry && !guardNoted(board.db, id, why)) tx(board, (db) => noteGuard(db, id, why)); };
|
|
1063
1272
|
for (const [i, c] of candidates.entries()) {
|
|
1064
|
-
|
|
1273
|
+
await yieldToOwner();
|
|
1274
|
+
if (stopping) return;
|
|
1275
|
+
const { task, plan } = c;
|
|
1065
1276
|
if (!plan.profile) { report.unassigned.push(task.id); note(task.id, 'no profile runs it (no assignee, and the workflow names none)'); continue; }
|
|
1066
1277
|
if (!plan.lane) { report.nonspawnable.push(task.id); note(task.id, `${plan.profile} is no lane of this home`); continue; }
|
|
1067
|
-
const capped =
|
|
1278
|
+
const capped = view(board, db => {
|
|
1279
|
+
const current = getTask(db, task.id);
|
|
1280
|
+
return current ? caps.admit(waitingEntries[i], scopeFor(current, { lane: plan.lane,
|
|
1281
|
+
run: { surface: plan.lane.surface, local: executionHost(plan.lane, current.machine, localMachine(), normalMachine) === localMachine() } }), waitingEntries)
|
|
1282
|
+
: { why: 'the selected card no longer exists' };
|
|
1283
|
+
});
|
|
1068
1284
|
let why = capped?.why ?? null;
|
|
1069
1285
|
// the launcher's own conditions: a lane that can start this card where it names
|
|
1070
1286
|
if (!why) why = launcherCheck(board.db, task, plan.lane, fleet);
|
|
@@ -1076,13 +1292,23 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
1076
1292
|
}
|
|
1077
1293
|
let moved, checked;
|
|
1078
1294
|
try {
|
|
1079
|
-
checked = await checkWorkspace(board, task);
|
|
1295
|
+
checked = await checkWorkspace(board, task, await workspaceForRun(board, task, { role: plan.runSpec?.role, slot: c.slot, lane: plan.lane }));
|
|
1080
1296
|
moved = tx(board, (db) => {
|
|
1081
1297
|
// The claim: another round (a second serve, a one-shot dispatch) may have started this card, or others that count
|
|
1082
1298
|
// against the same caps, since this round read the board. Its starts are counted, and this card starts only if it
|
|
1083
1299
|
// still waits where it did and still fits.
|
|
1084
1300
|
const now = getTask(db, task.id);
|
|
1085
|
-
if (!now || now.current_run_id || now.archived || statusOf(db, now) !==
|
|
1301
|
+
if (!now || now.subtask_of || now.current_run_id || now.archived || statusOf(db, now) !== c.status) throw Object.assign(new Error('started or moved by another round'), { claimedElsewhere: true });
|
|
1302
|
+
const scope = scopeFor(now, { lane: plan.lane, run: { surface: plan.lane.surface, local: executionHost(plan.lane, now.machine, localMachine(), normalMachine) === localMachine() } });
|
|
1303
|
+
const selectionReason = selectionWait(now, scope.card);
|
|
1304
|
+
if (selectionReason) throw Object.assign(new Error(selectionReason), { claimedElsewhere: true });
|
|
1305
|
+
const currentPlan = planOf(now, scope);
|
|
1306
|
+
if (!currentPlan || currentPlan.target !== plan.target || currentPlan.profile !== plan.profile
|
|
1307
|
+
|| ['model_override', 'provider_override', 'reasoning_effort', 'machine', 'workspace_kind', 'workspace_path', 'workspace_anchor', 'branch_name'].some(field => now[field] !== task[field])
|
|
1308
|
+
|| executionHost(plan.lane, now.machine, localMachine(), normalMachine) !== waitingEntries[i].machine
|
|
1309
|
+
|| String(templateValue(currentPlan.runSpec?.session ?? 'worker', scope) ?? 'worker') !== c.slot
|
|
1310
|
+
|| !holds(d.eligible ?? null, scope))
|
|
1311
|
+
throw Object.assign(new Error('start selection changed while the owner answered other work'), { claimedElsewhere: true });
|
|
1086
1312
|
for (const row of db.prepare('SELECT t.id, t.assignee, t.model_override, c.machine, r.metadata FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id JOIN task_runs r ON r.id=t.current_run_id WHERE t.current_run_id IS NOT NULL').all()) {
|
|
1087
1313
|
const lane = lanes[row.assignee] ?? {}, status = statusOf(db, row);
|
|
1088
1314
|
const session = json(row.metadata, {})?.supercode ?? {};
|
|
@@ -1093,9 +1319,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
1093
1319
|
}
|
|
1094
1320
|
const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
|
|
1095
1321
|
if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
|
|
1096
|
-
assertWorkspaceOwner(db,
|
|
1322
|
+
assertWorkspaceOwner(db, now, checked);
|
|
1097
1323
|
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
|
|
1098
|
-
if (!
|
|
1324
|
+
if (!now.assignee && plan.profile) {
|
|
1099
1325
|
db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
|
|
1100
1326
|
event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
|
|
1101
1327
|
}
|
|
@@ -1142,6 +1368,8 @@ const superseded = (taskId, runId) => Object.assign(new Error(`run ${runId} of $
|
|
|
1142
1368
|
/** A launch's preparation awaits (minting, workspace, doors) while the owner answers verbs: after each await, and right
|
|
1143
1369
|
* before it is issued, the run must still be the card's current, unended one (a cancel or restart meanwhile ends it). */
|
|
1144
1370
|
function assertRunCurrent(board, taskId, runId) {
|
|
1371
|
+
const task = view(board, db => getTask(db, taskId));
|
|
1372
|
+
if (task?.backlog) throw Object.assign(new Error(`${taskId} is in backlog; plan it before launching work`), { launchSuperseded: true });
|
|
1145
1373
|
if (!view(board, (db) => runCurrent(db, taskId, runId))) throw superseded(taskId, runId);
|
|
1146
1374
|
}
|
|
1147
1375
|
/** A write a launch makes for its run: made only while that run is still the card's current one, checked in the same
|
|
@@ -1155,7 +1383,10 @@ function runTx(board, taskId, runId, fn) {
|
|
|
1155
1383
|
function launchFailed(board, entry, id, error, payload) {
|
|
1156
1384
|
return tx(board, (db) => {
|
|
1157
1385
|
if (!runCurrent(db, id, error.launchRun)) return null;
|
|
1158
|
-
if (monitorsRuns(entry)
|
|
1386
|
+
if (monitorsRuns(entry) || error.continuationRequired) {
|
|
1387
|
+
if (error.continuationRequired) setRunSession(db, error.launchRun, { continuation_required: true });
|
|
1388
|
+
surfaceIncident(db, board, entry, id, 'spawn_failed', payload, error.launchRun); return { effects: [] };
|
|
1389
|
+
}
|
|
1159
1390
|
if (recoversRuns(entry)) retainRecovery(db, id, 'spawn_failed', payload, error.launchRun);
|
|
1160
1391
|
return fire(db, board, entry, id, 'spawn_failed', { roles: ['platform'], payload });
|
|
1161
1392
|
});
|
|
@@ -1175,12 +1406,8 @@ export async function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
1175
1406
|
|
|
1176
1407
|
async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
1177
1408
|
let task = getTaskNow(board, id);
|
|
1409
|
+
assertRunCurrent(board, id, runId);
|
|
1178
1410
|
if (task.current_run_id !== runId) throw superseded(id, runId);
|
|
1179
|
-
const checked = await checkWorkspace(board, task);
|
|
1180
|
-
if (checked) runTx(board, id, runId, (db) => {
|
|
1181
|
-
assertWorkspaceOwner(db, task, checked);
|
|
1182
|
-
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1183
|
-
});
|
|
1184
1411
|
const status = view(board, (db) => statusOf(db, task));
|
|
1185
1412
|
const spec = runSpec(entry, status, task);
|
|
1186
1413
|
if (!spec) return null;
|
|
@@ -1192,6 +1419,12 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1192
1419
|
// the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
|
|
1193
1420
|
// is a template, so a step can ask for isolation by naming a new one
|
|
1194
1421
|
const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
|
|
1422
|
+
const workspaceOptions = await workspaceForRun(board, task, { role: spec.role, slot, lane, runId });
|
|
1423
|
+
const checked = await checkWorkspace(board, task, workspaceOptions);
|
|
1424
|
+
if (checked) runTx(board, id, runId, (db) => {
|
|
1425
|
+
assertWorkspaceOwner(db, task, checked);
|
|
1426
|
+
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1427
|
+
});
|
|
1195
1428
|
// Explicit starts and deferred/resumed launches reach this path without startCards. Recheck the dispatcher-session
|
|
1196
1429
|
// caps here too, excluding this attempt's own reservation. Other cap kinds keep their existing admission rules.
|
|
1197
1430
|
const sessionCaps = entry.workflow.dispatch.capacity?.filter(cap => cap.counts === 'sessions' && cap.where?.dispatch === true) ?? [];
|
|
@@ -1234,16 +1467,29 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1234
1467
|
if (spec.role === 'reviewer') {
|
|
1235
1468
|
const worker = view(board, (db) => lastSession(db, id, 'worker'));
|
|
1236
1469
|
const reviewer = view(board, (db) => lastSession(db, id, slot));
|
|
1237
|
-
if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw
|
|
1470
|
+
if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw ownerRule('a reviewer must have a different session from the implementer');
|
|
1238
1471
|
}
|
|
1239
1472
|
// the model it runs is recorded with the session, for caps that count by model
|
|
1240
|
-
const meta = { status, role: spec.role, slot, model: task.model_override ?? lane.model ?? null, prompt_digest: told.digest, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
|
|
1473
|
+
const meta = { status, role: spec.role, slot, continuation_required: false, model: task.model_override ?? lane.model ?? null, prompt_digest: told.digest, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
|
|
1474
|
+
await retireUnissuedSlotLaunch(board, id, runId, slot);
|
|
1475
|
+
assertRunCurrent(board, id, runId);
|
|
1241
1476
|
try {
|
|
1242
1477
|
// Teams is never on a launch's path: the card's agent is its identity minted on this host under its retained intent,
|
|
1243
1478
|
// and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
|
|
1244
1479
|
const context=current().facts.SUPERCODE_CONTEXT,team=agentScope(context);
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1480
|
+
const decision = continuationDecision(board, task, runId, slot, lane, spec.role ?? slot);
|
|
1481
|
+
meta.slot_generation = decision.authorization ? randomUUID() : decision.prior?.generation ?? randomUUID();
|
|
1482
|
+
// Adoption never transfers ownership or creates launch identity. A later continuation keeps that policy.
|
|
1483
|
+
let agent = null;
|
|
1484
|
+
if (decision.prior && adoptedOnly(decision.prior.session) && !decision.authorization) {
|
|
1485
|
+
Object.assign(meta, { agent_id: null, agent_org: null, agent_context: null, agent_env: {},
|
|
1486
|
+
agent_profile_intent: structuredClone(resolveAgentProfile(entry.profiles, lane.name).resolved) });
|
|
1487
|
+
} else {
|
|
1488
|
+
agent=await localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
|
|
1489
|
+
profile:lane.name,card:id,handle:cardHandleOf(board,id),readableId:readableIdOf(board,id),role:spec.role??slot,
|
|
1490
|
+
// an authorized replacement binds only the agent its decision names; one naming none mints the role's next generation
|
|
1491
|
+
...((decision.authorization ? decision.authorization.target?.agent : decision.prior?.session?.agent_id)
|
|
1492
|
+
? { existingAgent: decision.authorization ? decision.authorization.target.agent : decision.prior.session.agent_id } : {})});
|
|
1247
1493
|
assertRunCurrent(board,id,runId);
|
|
1248
1494
|
recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
|
|
1249
1495
|
// G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
|
|
@@ -1261,15 +1507,16 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1261
1507
|
assertRunCurrent(board,id,runId);
|
|
1262
1508
|
if(surface==='headless')meta.agent_env.HERMES_HOME=agentHome;
|
|
1263
1509
|
else{const memory=await memorySnapshot(agentHome).catch(()=>'');assertRunCurrent(board,id,runId);if(memory)prompt=`${prompt}\n\n${memory}`;}
|
|
1510
|
+
}
|
|
1264
1511
|
const result = surface === 'pane'
|
|
1265
|
-
? 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 } })
|
|
1266
|
-
: await launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
|
|
1512
|
+
? await launchPane(board, task, runId, lane, fleet, { input: briefByReference(board, task, runId, spec.role ?? slot, where, prompt), slot, meta, workspaceOptions, orchestration: entry.orchestration ?? { profiles: entry.profiles } })
|
|
1513
|
+
: await launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta, workspaceOptions });
|
|
1267
1514
|
tx(board,db=>{
|
|
1268
1515
|
if(!runCurrent(db,id,runId))return; // replaced once launched: its session is the ended run's, closed as one
|
|
1269
1516
|
setRunSession(db,runId,{launch_deferred:false,launch_deferred_error:null,restart_authorized:false});
|
|
1270
1517
|
event(db,id,'restart_authorization_consumed',{},runId);
|
|
1271
1518
|
});
|
|
1272
|
-
if(result?.session_id)recordAgentRun(board.root,team.org,agent.identity.id,{session:`${result.harness}:${result.session_id}`,machine:result.machine,run:runId});
|
|
1519
|
+
if(result?.session_id&&agent)recordAgentRun(board.root,team.org,agent.identity.id,{session:`${result.harness}:${result.session_id}`,machine:result.machine,run:runId});
|
|
1273
1520
|
return result;
|
|
1274
1521
|
} catch(error) {
|
|
1275
1522
|
// An issued asynchronous launch may bind later. Its original permission
|
|
@@ -1294,6 +1541,25 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1294
1541
|
}
|
|
1295
1542
|
}
|
|
1296
1543
|
|
|
1544
|
+
function continuationDecision(board, task, runId, slot, lane, role, agent = undefined) {
|
|
1545
|
+
const assignment = cardAgentAssignments(board.root, task.id).find(item => item.id === cardRoleAssignment(task.id, role) && item.state === 'active');
|
|
1546
|
+
const decision = view(board, db => replacementFor(db, task.id, slot, { harness: lane.harness, cardMachine: task.machine,
|
|
1547
|
+
agent, assignment, beforeRun: runId }));
|
|
1548
|
+
const previous = decision.prior?.session;
|
|
1549
|
+
if (previous && !decision.authorization) {
|
|
1550
|
+
if (!previous.session_id) throw continuationRefusal(decision.prior, 'its issued launch has no known native conversation id');
|
|
1551
|
+
if (previous.forgotten) throw continuationRefusal(decision.prior, 'a forgotten marker has no current deliberate replacement decision');
|
|
1552
|
+
if ((previous.harness ?? 'claude-code') !== lane.harness) throw continuationRefusal(decision.prior, 'the configured harness changed without a matching manager decision');
|
|
1553
|
+
if (normalMachine(previous.machine) !== executionHost(lane, task.machine, localMachine(), normalMachine))
|
|
1554
|
+
throw continuationRefusal(decision.prior, 'the configured machine changed without a matching manager decision');
|
|
1555
|
+
if (assignment && previous.agent_id && assignment.agent_id !== previous.agent_id)
|
|
1556
|
+
throw continuationRefusal(decision.prior, 'the role owner changed without a matching assignment decision');
|
|
1557
|
+
if (agent !== undefined && previous.agent_id && previous.agent_id !== agent)
|
|
1558
|
+
throw continuationRefusal(decision.prior, 'the established conversation belongs to a different agent');
|
|
1559
|
+
}
|
|
1560
|
+
return { ...decision, assignment };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1297
1563
|
/**
|
|
1298
1564
|
* What the board types into a pane session to start a run: one short line in the board's own words naming the role,
|
|
1299
1565
|
* the card and where the run's brief is, with the brief itself kept in a file. A terminal harness takes its input as a
|
|
@@ -1324,16 +1590,16 @@ export function roleBrief(db, board, entry, task, status, surface = 'pane') {
|
|
|
1324
1590
|
if (!spec) return null;
|
|
1325
1591
|
const where = whereOf(board, task);
|
|
1326
1592
|
const nativeHolds = Boolean(entry.workflow.flags?.paused);
|
|
1327
|
-
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
1593
|
+
const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
|
|
1328
1594
|
// a subtask's run also sees its arc: the arc's text, tasks and thread (where its owner's rulings are)
|
|
1329
|
-
const prompt = render(spec.prompt, { ...scope, where, context: cardContext(db, task.id, { nativeHolds }),
|
|
1595
|
+
const prompt = render(spec.prompt, { ...scope, where, context: cardContext(db, task.id, { nativeHolds, review: spec.role === 'reviewer' }),
|
|
1330
1596
|
arc_context: task.subtask_of ? cardContext(db, task.subtask_of, { nativeHolds }) : '', doors: doors(entry.workflow, status, spec.role, task.id, where, surface) });
|
|
1331
1597
|
return { spec, where, prompt, digest: promptDigest(spec) };
|
|
1332
1598
|
}
|
|
1333
1599
|
const promptDigest = (spec) => spec?.prompt ? createHash('sha256').update(String(spec.prompt)).digest('hex').slice(0, 16) : null;
|
|
1334
1600
|
|
|
1335
|
-
/** File a role's brief (roleBrief) to the session at `address` on run `runId`, by reference, as a session notice
|
|
1336
|
-
* caller's transaction, and record which prompt it was. */
|
|
1601
|
+
/** File a role's brief (roleBrief) to the session at `address` on run `runId`, by reference, as a session notice effect
|
|
1602
|
+
* in the caller's transaction (sessionNotice files it as the run's instruction), and record which prompt it was. */
|
|
1337
1603
|
function queueBrief(db, board, task, runId, address, told, how) {
|
|
1338
1604
|
const role = told.spec.role ?? 'implementer';
|
|
1339
1605
|
const text = briefByReference(board, task, runId, role, told.where, told.prompt, how);
|
|
@@ -1342,6 +1608,7 @@ function queueBrief(db, board, task, runId, address, told, how) {
|
|
|
1342
1608
|
messageKey: `board-brief-${task.id}-${runId}-${told.digest}-${how}-${createHash('sha256').update(address).digest('hex').slice(0, 12)}` };
|
|
1343
1609
|
db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
1344
1610
|
setRunSession(db, runId, { prompt_digest: told.digest });
|
|
1611
|
+
return source;
|
|
1345
1612
|
}
|
|
1346
1613
|
|
|
1347
1614
|
/**
|
|
@@ -1349,16 +1616,19 @@ function queueBrief(db, board, task, runId, address, told, how) {
|
|
|
1349
1616
|
* it was told (its run's `prompt_digest`; a run that records none was never told this one), as a room's guidance is told
|
|
1350
1617
|
* again on every change. A session being launched, closed, released or in a person's hands is told nothing here.
|
|
1351
1618
|
*/
|
|
1352
|
-
function retellBriefs(board, entry, report) {
|
|
1619
|
+
async function retellBriefs(board, entry, report) {
|
|
1353
1620
|
// each card in its own write: a brief that cannot be told (its prompt unreadable, its card changed) is said in the
|
|
1354
1621
|
// round's report and stops no other card's
|
|
1355
1622
|
for (const { id } of view(board, (db) => db.prepare('SELECT id FROM tasks WHERE current_run_id IS NOT NULL').all())) {
|
|
1623
|
+
await yieldToOwner();
|
|
1624
|
+
if (stopping) return;
|
|
1356
1625
|
try {
|
|
1357
1626
|
tx(board, (db) => {
|
|
1358
1627
|
const run = currentRun(db, id), session = run?.metadata?.supercode, address = addressOf(session);
|
|
1359
1628
|
if (!run || run.ended_at != null || !address || session.surface === 'headless' || session.launch_pending || session.launch_deferred
|
|
1360
1629
|
|| session.close_requested || session.close_confirmed_at || session.released || detachedSession(db, session)) return;
|
|
1361
1630
|
const task = getTask(db, id), status = statusOf(db, task);
|
|
1631
|
+
if (task.backlog || task.paused || task.blocked_by.length || scopeOf(db, board, task, { params: entry.params, lazy: true }).card.dispatch_hold) return;
|
|
1362
1632
|
if (promptDigest(runSpec(entry, status, task)) === (session.prompt_digest ?? null)) return;
|
|
1363
1633
|
const told = roleBrief(db, board, entry, task, status, 'pane');
|
|
1364
1634
|
if (!told?.digest) return;
|
|
@@ -1384,38 +1654,101 @@ function pendingBrief(board, task, runId, where) {
|
|
|
1384
1654
|
return existsSync(file) ? `Run ${runId}'s instructions from the board are its brief ${briefFrom(board, task, file, where)}, and carry it out.` : null;
|
|
1385
1655
|
}
|
|
1386
1656
|
|
|
1387
|
-
/** Start one card now
|
|
1388
|
-
export async function startCard(board, id,
|
|
1657
|
+
/** Start one card now, or retain the exact unmet admission detail without claiming a launch. */
|
|
1658
|
+
export async function startCard(board, id, options = {}) {
|
|
1659
|
+
try { return await startCardNow(board, id, options); }
|
|
1660
|
+
catch (error) {
|
|
1661
|
+
if (error.ownerRule) return routeRequest(board, { id, name: 'start', payload: {}, reason: error });
|
|
1662
|
+
if (!error.boardDetail && !error.refusal && !error.noTransition && !error.continuationRequired
|
|
1663
|
+
&& !error.launchDeferred && !error.launchPending && error.code !== 'review_refused') throw error;
|
|
1664
|
+
return acceptRequest(board, { id, name: 'start', payload: {}, reason: error });
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function startCardNow(board, id, { roles = ['manager'], lanes, settings } = {}) {
|
|
1389
1669
|
// the board's own published fleet projection, as a tick reads it: a manager's or creator's start that continues the
|
|
1390
1670
|
// slot's session checks that session through it
|
|
1391
1671
|
fleetRoot = board.root;
|
|
1392
1672
|
const entry = await loadWorkflow(board.root);
|
|
1673
|
+
if (!roles.some(role => ['manager', 'creator'].includes(role))) throw ownerRule('Only the manager or creator starts a held card; request its decision.');
|
|
1674
|
+
lanes ??= lanesOf(entry.profiles);
|
|
1675
|
+
settings ??= boardSettings(entry.params);
|
|
1676
|
+
const expiryEffects = tx(board, db => { const effects = []; settleExpiringFlags(db, board, entry, effects); return effects; });
|
|
1677
|
+
queueEffects(board, expiryEffects);
|
|
1678
|
+
const stop = paused(board.root);
|
|
1679
|
+
if (stop) throw requestDetail(`the board is stopped: ${stop.reason ?? stop}`);
|
|
1680
|
+
const before = getTaskNow(board, id);
|
|
1681
|
+
if (!before) throw requestDetail(`no card ${id}`);
|
|
1393
1682
|
const fleet = await observeFleet();
|
|
1394
|
-
const
|
|
1683
|
+
const pressure = memoryPressure(), tickState = { local_started: 0 };
|
|
1684
|
+
const planOf = (db, task) => {
|
|
1685
|
+
if (!task) throw requestDetail(`no card ${id}`);
|
|
1686
|
+
const scope = scopeOf(db, board, task, { params: entry.params, extra: { sender: roles, verb: entry.workflow.dispatch.start,
|
|
1687
|
+
event: { lock: claimer() }, host: hostOf(pressure), tick: tickState } });
|
|
1688
|
+
// Explicit start releases this hold in the admitted transaction below. Preview that same transition.
|
|
1689
|
+
scope.card.dispatch_hold = null;
|
|
1690
|
+
const status = statusOf(db, task);
|
|
1691
|
+
const transition = transitionsFor(entry.workflow, status, entry.workflow.dispatch.start)
|
|
1692
|
+
.find(t => (!t.by?.length || t.by.some(role => roles.includes(role))) && holds(t.guard ?? null, scope));
|
|
1693
|
+
if (!transition) throw requestDetail(`${id} has no admitted ${entry.workflow.dispatch.start} transition from ${status}`);
|
|
1694
|
+
const target = transition.target ? String(templateValue(transition.target, scope)) : status;
|
|
1695
|
+
const spec = runSpec(entry, target, scope.card);
|
|
1696
|
+
const profile = spec ? evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
|
|
1697
|
+
const lane = profile ? lanes[profile] : null;
|
|
1698
|
+
if (spec && !lane) throw requestDetail(`${id} would be ${target}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
|
|
1699
|
+
return { task, scope, target, spec, profile, lane, key: JSON.stringify([status, task.current_run_id, task.assignee, task.machine,
|
|
1700
|
+
task.model_override, task.provider_override, target, spec?.role, spec?.session, profile, lane?.harness, lane?.surface]) };
|
|
1701
|
+
};
|
|
1702
|
+
const plan = view(board, db => planOf(db, getTask(db, id)));
|
|
1703
|
+
const checked = await checkWorkspace(board, before, await workspaceForRun(board, before, { role: plan.spec?.role,
|
|
1704
|
+
slot: String(templateValue(plan.spec?.session ?? 'worker', plan.scope) ?? 'worker'), lane: plan.lane }));
|
|
1705
|
+
const held = await homeHoldings(board.root, listBoards(board.root), lanes, fleet, entry, { retainRevisions: true });
|
|
1706
|
+
try {
|
|
1707
|
+
const admissionOf = p => ({ card: id, board: board.slug, machine: executionHost(p.lane, p.task.machine, localMachine(), normalMachine),
|
|
1708
|
+
assignee: p.profile ?? null, status: p.target, stored: p.target, dispatch: true,
|
|
1709
|
+
slot: String(templateValue(p.spec?.session ?? 'worker', p.scope) ?? 'worker'),
|
|
1710
|
+
model: p.task.model_override ?? p.lane?.model ?? held.defaults?.[p.lane?.harness] ?? null, harness: p.lane?.harness ?? null });
|
|
1711
|
+
const caps = capacity(entry.workflow.dispatch.capacity, held, evaluate);
|
|
1712
|
+
const refused = caps.admit(admissionOf(plan), { ...plan.scope, lane: plan.lane ?? {} });
|
|
1713
|
+
if (refused) throw requestDetail(refused.why);
|
|
1395
1714
|
const moved = tx(board, (db) => {
|
|
1396
|
-
|
|
1715
|
+
const task = getTask(db, id);
|
|
1716
|
+
if (task?.backlog) throw requestDetail(`${id} is in backlog; plan it before starting it`);
|
|
1717
|
+
const currentPlan = planOf(db, task);
|
|
1718
|
+
if (currentPlan.key !== plan.key) throw requestDetail(`${id}'s role or configuration changed during start admission; retry its current plan`);
|
|
1719
|
+
// The full home projection, including stopped card holdings, must still be the one admitted above.
|
|
1720
|
+
for (const [slug, { board: reading, revision }] of held.revisions) {
|
|
1721
|
+
const change = currentRecordChanges(reading.db, revision);
|
|
1722
|
+
// Owner fencing updates board state at transaction entry; only current card/run/event changes invalidate capacity.
|
|
1723
|
+
if (change.reset || change.cards.length || change.eventsRewritten)
|
|
1724
|
+
throw requestDetail(`capacity of board ${slug} changed during start admission; retry against its current holdings`);
|
|
1725
|
+
}
|
|
1726
|
+
const capped = caps.admit(admissionOf(currentPlan), { ...currentPlan.scope, lane: currentPlan.lane ?? {} });
|
|
1727
|
+
if (capped) throw requestDetail(capped.why);
|
|
1728
|
+
if (detachedCard(db, id)) throw requestDetail(`${id} is detached to a person; request and verify adoption before starting it`);
|
|
1397
1729
|
const person = personOf(getTask(db, id)?.assignee);
|
|
1398
|
-
if (person) throw
|
|
1730
|
+
if (person) throw requestDetail(`${id} is assigned to ${person}: a person's card runs no session; it closes when that person completes it`);
|
|
1399
1731
|
assertWorkspaceOwner(db, before, checked);
|
|
1400
1732
|
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1401
|
-
if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
|
|
1402
1733
|
event(db, id, 'dispatch_released', { reason: 'explicit start' });
|
|
1403
1734
|
requestSessionClose(db,id);
|
|
1404
1735
|
const moved = fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } });
|
|
1405
1736
|
setRunSession(db, getTask(db,id).current_run_id, { restart_authorized: true });
|
|
1406
1737
|
return moved;
|
|
1407
1738
|
});
|
|
1739
|
+
queueEffects(board, moved.effects);
|
|
1408
1740
|
const task = getTaskNow(board, id);
|
|
1409
1741
|
const spec = runSpec(entry, moved.to, task);
|
|
1410
1742
|
const profile = spec ? view(board, (db) => evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params }))) : null;
|
|
1411
1743
|
const lane = profile ? lanes[profile] : null;
|
|
1412
|
-
if (spec && !lane) throw
|
|
1744
|
+
if (spec && !lane) throw requestDetail(`${id} is ${moved.to}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
|
|
1413
1745
|
if (!lane) return null;
|
|
1414
1746
|
try { return await launchRun(board,entry,id,lane,fleet,settings); }
|
|
1415
1747
|
catch (error) {
|
|
1416
|
-
if (monitorsRuns(entry) && !error.launchPending && !error.launchSuperseded) launchFailed(board, entry, id, error, { error: error.message, origin: originOf(error) });
|
|
1748
|
+
if ((monitorsRuns(entry) || error.continuationRequired) && !error.launchPending && !error.launchSuperseded) launchFailed(board, entry, id, error, { error: error.message, origin: originOf(error) });
|
|
1417
1749
|
throw error;
|
|
1418
1750
|
}
|
|
1751
|
+
} finally { held.releaseRevisions(); }
|
|
1419
1752
|
}
|
|
1420
1753
|
|
|
1421
1754
|
/** Reconcile durable close intent and ended runs, including old unverified closed_at records.
|
|
@@ -1442,6 +1775,9 @@ const TURN_IN_PROGRESS = new Set(['working', 'needs_input']);
|
|
|
1442
1775
|
* A harness's own blocking prompt on the pane's screen, which no input from the board answers: the session is stopped,
|
|
1443
1776
|
* not between turns. Claude Code's usage limit ("What do you want to do? 1. Stop and wait for limit to reset …") is
|
|
1444
1777
|
* one; its reset time is said when the prompt names it. Null when the screen shows none.
|
|
1778
|
+
*
|
|
1779
|
+
* Read off the pane's screen through its door at the decision (turnAtClose), never from the held record: a held
|
|
1780
|
+
* reading lags the host by the record's cycle, and a limit screen read a round late closes the session a round late.
|
|
1445
1781
|
*/
|
|
1446
1782
|
const BLOCKING_PROMPTS = [
|
|
1447
1783
|
{ reason: 'provider-limit', harness: 'claude-code', test: /Stop and wait for limit to reset/i, resets: /continue automatically at ([^\n│]+?)\s*(?:│|$)/im },
|
|
@@ -1468,19 +1804,34 @@ function unreadWatch(held, mark, gap, at = now()) {
|
|
|
1468
1804
|
}
|
|
1469
1805
|
async function turnAtClose(session, pane = null, { launchWindow = null, quiet = null, gap = 0, inputAbandonment = null } = {}) {
|
|
1470
1806
|
const machine = session.machine ?? localMachine();
|
|
1471
|
-
const recordOf = (rows, id, harness) => rows.find((row) => row.locator?.session_id === id && row.locator?.harness === harness);
|
|
1472
1807
|
let activity, rows = null;
|
|
1473
|
-
|
|
1474
|
-
// the
|
|
1475
|
-
|
|
1808
|
+
const homes = (await readNativeHome(session))?.homes;
|
|
1809
|
+
// A home the board never recorded never holds a close: the pane's own door (its machine's listing) reads the turn
|
|
1810
|
+
// from the harness lifecycle under the pane, which its machine knows without the home (ADR 0019).
|
|
1811
|
+
if (!homes) {
|
|
1812
|
+
if (!pane) return { why: `the native home of ${addressOf(session)} is unknown and no pane of it is listed to read` };
|
|
1813
|
+
if (unboundLaunch(session, launchWindow)) return null;
|
|
1814
|
+
const evidence = pane.evidence ?? {};
|
|
1815
|
+
if (evidence.liveness === 'ended') return null;
|
|
1816
|
+
if (evidence.liveness === 'running') return { why: `pane ${session.pane} is in a turn, as its pane's own reading says` };
|
|
1817
|
+
if (evidence.signal === 'asking') return { why: `pane ${session.pane} waits on a question, as its pane's own reading says` };
|
|
1818
|
+
if (evidence.liveness === 'waiting' && evidence.livenessSource === 'cooperative') return null;
|
|
1819
|
+
const command = String(pane.command ?? '').split(/[\\/]/).pop().toLowerCase();
|
|
1820
|
+
if (command && !HARNESS_COMMANDS.has(command)) return null;
|
|
1821
|
+
return { why: `pane ${session.pane}'s turn could not be read through its pane's door (${evidence.liveness ?? 'no reading'})` };
|
|
1822
|
+
}
|
|
1476
1823
|
if (pane?.pid) {
|
|
1477
|
-
const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid],
|
|
1824
|
+
const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid], homes }, { timeout: NATIVE_DISCOVERY_MS });
|
|
1478
1825
|
const page = r.code === 0 ? lastJson(r.stdout) : null;
|
|
1479
1826
|
if (!Array.isArray(page?.activities)) return { why: `the session in pane ${session.pane} could not be read just now (${(r.stderr || r.stdout).trim().slice(-160)})` };
|
|
1480
|
-
|
|
1827
|
+
const observed = page.activities.find((item) => item.pid === pane.pid);
|
|
1828
|
+
activity = observed?.activity ?? null;
|
|
1829
|
+
if (activity && (!inNativeHome(observed, homes) || (session.session_id &&
|
|
1830
|
+
(activity.session_id !== session.session_id || activity.harness !== session.harness))))
|
|
1831
|
+
return { why: `pane ${session.pane} has no verified activity for this native identity` };
|
|
1481
1832
|
// No session read under the pane is no absence (a registry it could not read, another config home, a failed process
|
|
1482
1833
|
// read, a process walk cut short): the pane's own foreground program says whether a harness runs there. A pane
|
|
1483
|
-
// running none is closed; one running a harness
|
|
1834
|
+
// running none is closed; one running a harness requires its own process/source receipt.
|
|
1484
1835
|
if (!activity) {
|
|
1485
1836
|
// A pane the board launched whose session no reading ever found (the fleet's never bound it, and none runs under
|
|
1486
1837
|
// its process now) past the launch window holds a harness that never started a session: nothing is cut by its
|
|
@@ -1488,19 +1839,13 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1488
1839
|
if (unboundLaunch(session, launchWindow)) return null;
|
|
1489
1840
|
const command = String(pane.command ?? '').split(/[\\/]/).pop().toLowerCase();
|
|
1490
1841
|
if (command && !HARNESS_COMMANDS.has(command)) return null;
|
|
1491
|
-
|
|
1492
|
-
if (!session.session_id) return { why: unfound };
|
|
1493
|
-
try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, codexHome)).sessions; }
|
|
1494
|
-
catch (error) { return { why: `${unfound} (${String(error.message).slice(0, 160)})` }; }
|
|
1495
|
-
activity = recordOf(rows, session.session_id, session.harness ?? 'claude-code')?.activity ?? null;
|
|
1496
|
-
// a row that shows no live owner beside a pane that runs a harness places nothing: another session may run there
|
|
1497
|
-
if (activity?.presence !== 'running') return { why: unfound };
|
|
1842
|
+
return { why: `pane ${session.pane} runs ${command || 'a program it does not name'}, whose native identity could not be read` };
|
|
1498
1843
|
}
|
|
1499
1844
|
} else if (pane) {
|
|
1500
1845
|
return { why: `pane ${session.pane} names no process to read` };
|
|
1501
1846
|
} else {
|
|
1502
1847
|
if (!session.session_id) return { why: 'its session is not known yet' };
|
|
1503
|
-
try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id,
|
|
1848
|
+
try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, homes)).sessions; }
|
|
1504
1849
|
catch (error) { return { why: `its session could not be read just now (${String(error.message).slice(0, 160)})` }; }
|
|
1505
1850
|
activity = nativeReading(rows, session).row?.activity ?? null;
|
|
1506
1851
|
if (!activity) return { why: `its session ${addressOf(session)} is not in its machine's discovery` };
|
|
@@ -1524,9 +1869,9 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1524
1869
|
if (blocked) return { stopped: blocked };
|
|
1525
1870
|
const sessionId = activity.session_id ?? session.session_id, harness = activity.harness ?? session.harness ?? 'claude-code';
|
|
1526
1871
|
if (!sessionId || !quiet) return { why: unknown };
|
|
1527
|
-
if (!rows) try { rows = (await discoverNative(machine, harness, sessionId,
|
|
1872
|
+
if (!rows) try { rows = (await discoverNative(machine, harness, sessionId, homes)).sessions; }
|
|
1528
1873
|
catch (error) { return { why: `${unknown}, and its record could not be read just now (${String(error.message).slice(0, 160)})` }; }
|
|
1529
|
-
const record =
|
|
1874
|
+
const record = nativeReading(rows, { session_id: sessionId, harness }).row;
|
|
1530
1875
|
if (TURN_IN_PROGRESS.has(record?.activity?.turn)) return inTurn(record.activity.turn);
|
|
1531
1876
|
const mark = Math.max(Number(record?.updated_at_ms) || 0, Number(activity.evidence?.native_state_since_ms) || 0);
|
|
1532
1877
|
if (!mark) return { why: `${unknown}, and its record shows no write to count from` };
|
|
@@ -1587,21 +1932,46 @@ function recordInputClose(board, row, session, reading) {
|
|
|
1587
1932
|
});
|
|
1588
1933
|
}
|
|
1589
1934
|
|
|
1935
|
+
/** Pending close candidates only, in finite indexed pages. */
|
|
1936
|
+
async function* closeCandidates(board, taskId) {
|
|
1937
|
+
let cursor = 0;
|
|
1938
|
+
const high = view(board, db => Number(db.prepare('SELECT MAX(id) AS id FROM task_runs').get()?.id ?? 0));
|
|
1939
|
+
for (;;) {
|
|
1940
|
+
const rows = view(board, db => db.prepare(`SELECT id,task_id,profile,ended_at,metadata FROM task_runs
|
|
1941
|
+
WHERE ${CLOSE_OPEN_RUNS} AND id>? AND id<=?${taskId ? ' AND task_id=?' : ''} ORDER BY id LIMIT 32`)
|
|
1942
|
+
.all(cursor, high, ...(taskId ? [taskId] : [])));
|
|
1943
|
+
if (!rows.length) return;
|
|
1944
|
+
for (const row of rows) yield row;
|
|
1945
|
+
cursor = rows.at(-1).id;
|
|
1946
|
+
if (rows.length < 32) return;
|
|
1947
|
+
await yieldToOwner();
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1590
1951
|
/** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
|
|
1591
1952
|
const closesUnderway = new Map();
|
|
1592
1953
|
export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
1593
|
-
const
|
|
1594
|
-
//
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1954
|
+
const onTask = taskId ? ' AND task_id=?' : '';
|
|
1955
|
+
// Only unended references can need transfer repair. Seek each one's latest successor by
|
|
1956
|
+
// its indexed identity, instead of decoding every historical run to discover the same fact.
|
|
1957
|
+
const transfers = new Map();
|
|
1958
|
+
let repairCursor = 0;
|
|
1959
|
+
for (;;) {
|
|
1960
|
+
const page = view(board, db => db.prepare(`SELECT id,task_id,metadata FROM task_runs
|
|
1961
|
+
WHERE ended_at IS NULL AND id>?${onTask} ORDER BY id LIMIT 32`).all(repairCursor, ...(taskId ? [taskId] : [])));
|
|
1962
|
+
if (!page.length) break;
|
|
1963
|
+
for (const row of page) {
|
|
1964
|
+
const session = json(row.metadata, {})?.supercode;
|
|
1965
|
+
for (const name of ['session_id', 'pane']) if (session?.[name]) {
|
|
1966
|
+
const successor = view(board, db => db.prepare(`SELECT id,task_id,metadata FROM task_runs WHERE task_id=?
|
|
1967
|
+
AND ${runField('machine')} IS ? AND ${runField(name)}=? AND id>? ORDER BY id DESC LIMIT 1`)
|
|
1968
|
+
.get(row.task_id, session.machine ?? null, session[name], row.id));
|
|
1969
|
+
if (successor) transfers.set(successor.id, successor);
|
|
1970
|
+
}
|
|
1604
1971
|
}
|
|
1972
|
+
repairCursor = page.at(-1).id;
|
|
1973
|
+
if (page.length < 32) break;
|
|
1974
|
+
await yieldToOwner();
|
|
1605
1975
|
}
|
|
1606
1976
|
if (transfers.size) tx(board, (db) => {
|
|
1607
1977
|
for (const row of transfers.values()) {
|
|
@@ -1613,15 +1983,15 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1613
1983
|
// Each machine's pane list, read once per pass: its live panes, and its dead ones. A dead pane is still the machine's
|
|
1614
1984
|
// until it is closed (a pane whose program failed is kept, marked dead, for its last screen), so it is closed as any.
|
|
1615
1985
|
const panes = new Map();
|
|
1986
|
+
// asked of the machine's door as this board, which lists the panes the board's grants there let it observe
|
|
1616
1987
|
const paneListOn = async (machine) => {
|
|
1617
1988
|
if (!panes.has(machine)) {
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
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 }])), dead: new Set(list.filter(p => p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
|
|
1989
|
+
const { list, error } = await paneRows(machine, { timeout: 60_000, as: boardAddress(board.label) });
|
|
1990
|
+
panes.set(machine, list ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null, evidence: p.evidence ?? null }])), dead: new Set(list.filter(p => p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${error}` });
|
|
1621
1991
|
}
|
|
1622
1992
|
return panes.get(machine);
|
|
1623
1993
|
};
|
|
1624
|
-
for (const row of
|
|
1994
|
+
for await (const row of closeCandidates(board, taskId)) {
|
|
1625
1995
|
await yieldToOwner();
|
|
1626
1996
|
let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1627
1997
|
if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
|
|
@@ -1634,17 +2004,17 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1634
2004
|
if (session.mode === 'adopt' && session.address) {
|
|
1635
2005
|
tx(board, (db) => setRunSession(db, row.id, { launch_pending: false, launch_key: null, launch_error: null }));
|
|
1636
2006
|
session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1637
|
-
} else if (!session.pane && !session.address && !session.pid) {
|
|
2007
|
+
} else if ((!session.pane && !session.address && !session.pid) || (session.launch_not_issued && session.mode === 'fresh')) {
|
|
1638
2008
|
// settled only on a fleet reading that shows no session with its id on its machine; a lost open answer may
|
|
1639
|
-
// have opened one, and that is closed as its own once it is bound
|
|
1640
|
-
if (launchReading(fleet, session) !== 'absent') { report.undelivered.push([row.task_id, session.launch_key, 'close waits for a fleet reading that shows its launch absent', 'close']); continue; }
|
|
2009
|
+
// have opened one, and that is closed as its own once it is bound. A machine-cancelled absent key cannot arrive late.
|
|
2010
|
+
if (!session.launch_not_issued && launchReading(fleet, session) !== 'absent') { report.undelivered.push([row.task_id, session.launch_key, 'close waits for a fleet reading that shows its launch absent', 'close']); continue; }
|
|
1641
2011
|
const settled = tx(board, (db) => {
|
|
1642
2012
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1643
|
-
if (!current || current.launch_key !== session.launch_key || current.pane || current.close_confirmed_at) return false;
|
|
2013
|
+
if (!current || current.launch_key !== session.launch_key || (current.pane && !current.launch_not_issued) || current.close_confirmed_at) return false;
|
|
1644
2014
|
const at = now();
|
|
1645
2015
|
setRunSession(db, row.id, { launch_pending: false, close_requested: current.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null,
|
|
1646
2016
|
launch_error: current.launch_error ?? 'no pane was bound to its launch', forgotten: current.mode === 'fresh' ? true : Boolean(current.forgotten) });
|
|
1647
|
-
event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: 'no pane was bound to its launch, and the fleet reading shows neither a pane nor its session' }, row.id);
|
|
2017
|
+
event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: session.launch_not_issued ? 'the machine retired its absent launch key as not issued' : 'no pane was bound to its launch, and the fleet reading shows neither a pane nor its session' }, row.id);
|
|
1648
2018
|
return true;
|
|
1649
2019
|
});
|
|
1650
2020
|
if (settled) (report.closed ??= []).push({ task_id: row.task_id, ...session });
|
|
@@ -1652,7 +2022,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1652
2022
|
}
|
|
1653
2023
|
}
|
|
1654
2024
|
if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
|
|
1655
|
-
if ((!session?.pane && session?.surface
|
|
2025
|
+
if ((!session?.pane && !['native', 'headless'].includes(session?.surface)) || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
|
|
1656
2026
|
if (caller && session.session_id === caller) continue; // let the command that ended its own run return
|
|
1657
2027
|
// The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
|
|
1658
2028
|
// given) is released instead: left running, its slot freed. Closing one failed on every round for a session no close
|
|
@@ -1688,6 +2058,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1688
2058
|
else if ((await paneListOn(session.machine ?? localMachine())).dead?.has(session.pane)) continue;
|
|
1689
2059
|
}
|
|
1690
2060
|
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
|
|
2061
|
+
for (const run of active) await readNativeHome(json(run.metadata, {})?.supercode);
|
|
1691
2062
|
// Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
|
|
1692
2063
|
// same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
|
|
1693
2064
|
// only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
|
|
@@ -1711,25 +2082,35 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1711
2082
|
try {
|
|
1712
2083
|
const claimed = tx(board, (db) => {
|
|
1713
2084
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1714
|
-
if (!current || detachedSession(db, current) || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id ||
|
|
2085
|
+
if (!current || detachedSession(db, current) || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || current.pid !== session.pid
|
|
2086
|
+
|| homeIdentity(current) !== homeIdentity(session) || claimHolds(current.close_claim)) return false;
|
|
1715
2087
|
requestSessionClose(db, row.task_id, { runId: row.id });
|
|
1716
2088
|
// a close this process already claims, with a minute of its claim left, is tried again without a new claim
|
|
1717
2089
|
const own = current.close_claim?.lock === claimer() && Number(current.close_claim.expires) > now() + 60;
|
|
1718
2090
|
if (!own) setRunSession(db, row.id, { close_claim: newClaim(ttl ?? boardSettings(workflowFor(board.root).params).ttl) });
|
|
1719
|
-
|
|
2091
|
+
const retained = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(row.id)?.metadata, {})?.supercode;
|
|
2092
|
+
return { lock: retained.close_claim.lock, expires: retained.close_claim.expires, requested: retained.close_requested,
|
|
2093
|
+
monitor: retained.monitor_held, retained: retained.retain_after_run };
|
|
1720
2094
|
});
|
|
1721
2095
|
if (!claimed) continue;
|
|
1722
2096
|
noteClaim(board, 'runs', row.id);
|
|
2097
|
+
const revalidate = () => requireDeferredClose(board, row, session, claimed);
|
|
1723
2098
|
try {
|
|
1724
2099
|
const machine = session.machine ?? localMachine();
|
|
1725
|
-
if (session.surface === '
|
|
1726
|
-
|
|
2100
|
+
if (session.surface === 'headless') {
|
|
2101
|
+
await stopSession(board, session, revalidate);
|
|
2102
|
+
} else if (session.surface === 'native' && !session.pane) {
|
|
2103
|
+
const reading = nativeReading(await readNative(machine, session.harness, session.session_id, nativeHomes(session)), session);
|
|
1727
2104
|
if (reading.alive == null) throw new Error(`close deferred: native liveness unknown for ${addressOf(session)}`);
|
|
1728
2105
|
if (reading.alive) {
|
|
1729
|
-
const protectedNow = active.some(r =>
|
|
2106
|
+
const protectedNow = active.some(r => {
|
|
2107
|
+
const current = json(r.metadata, {})?.supercode;
|
|
2108
|
+
return addressOf(current) === addressOf(session)
|
|
2109
|
+
&& (!homeIdentity(current) || !homeIdentity(session) || sameNativeSession(current, session));
|
|
2110
|
+
});
|
|
1730
2111
|
if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
|
|
1731
2112
|
if (row.ended_at) { const { idleQuiet: quiet, interval } = boardSettings(workflowFor(board.root).params); await holdForTurn(session, null, { quiet, gap: Math.max(3 * interval, quiet / 2) }); }
|
|
1732
|
-
await stopSession(board, session);
|
|
2113
|
+
await stopSession(board, session, revalidate);
|
|
1733
2114
|
}
|
|
1734
2115
|
} else if (!finalFailure) {
|
|
1735
2116
|
const observed = await paneListOn(machine);
|
|
@@ -1741,7 +2122,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1741
2122
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
1742
2123
|
}
|
|
1743
2124
|
// a dead pane's program has ended: it has no turn to close between, and is closed at once
|
|
1744
|
-
if (dead) { await stopSession(board, session); observed.dead.delete(session.pane); }
|
|
2125
|
+
if (dead) { await stopSession(board, session, revalidate); observed.dead.delete(session.pane); }
|
|
1745
2126
|
else if (observed.live.has(session.pane)) {
|
|
1746
2127
|
// An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
|
|
1747
2128
|
// already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
|
|
@@ -1759,10 +2140,11 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1759
2140
|
} else if (reading && !recordInputClose(board, row, session, reading))
|
|
1760
2141
|
throw new Error('close deferred: ownership changed before ending its input dialog');
|
|
1761
2142
|
}
|
|
1762
|
-
await stopSession(board, session); observed.live.delete(session.pane);
|
|
2143
|
+
await stopSession(board, session, revalidate); observed.live.delete(session.pane);
|
|
1763
2144
|
}
|
|
1764
2145
|
}
|
|
1765
2146
|
} catch (e) {
|
|
2147
|
+
if (e.closeSuperseded) { report.undelivered.push([row.task_id, addressOf(session), e.message, 'close']); continue; }
|
|
1766
2148
|
// a deferred close (an unreachable door, an unread pane list or session discovery) waits for the next round;
|
|
1767
2149
|
// the prefix is decided on the whole message, before it is cut to size
|
|
1768
2150
|
const message = String(e.message), deferred = Boolean(e.launchDeferred) || /^close deferred: /.test(message);
|
|
@@ -1770,7 +2152,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1770
2152
|
// the watch a turn reading left (or ended: a turn read in progress); an attempt that took none keeps the last
|
|
1771
2153
|
if ('closeUnread' in e) unread = e.closeUnread;
|
|
1772
2154
|
}
|
|
1773
|
-
tx(board, (db) => {
|
|
2155
|
+
const confirmed = tx(board, (db) => {
|
|
2156
|
+
if (!deferredCloseCurrent(board, row, session, claimed)) return false;
|
|
1774
2157
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1775
2158
|
if (!current || detachedSession(db, current) || current.pane !== session.pane || current.session_id !== session.session_id) return;
|
|
1776
2159
|
const at = now();
|
|
@@ -1798,18 +2181,64 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1798
2181
|
event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine, unsubscribed,
|
|
1799
2182
|
...(finalFailure ? { reason: 'authorized pane presence observed absence after an earlier close refusal' } : {}) }, row.id);
|
|
1800
2183
|
}
|
|
2184
|
+
return true;
|
|
1801
2185
|
});
|
|
1802
2186
|
if (error) report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
|
|
1803
|
-
else { closed = true; (report.closed ??= []).push({ task_id: row.task_id, ...session }); }
|
|
2187
|
+
else if (confirmed) { closed = true; (report.closed ??= []).push({ task_id: row.task_id, ...session }); }
|
|
1804
2188
|
} finally { closesUnderway.delete(flight); settle({ error, closed }); }
|
|
1805
2189
|
}
|
|
2190
|
+
// A backlog request that first met a busy or unread native session remains its dispatch hold. Once the existing
|
|
2191
|
+
// close pass records every stop/release receipt, apply that retained request as its original sender.
|
|
2192
|
+
const backlogged = await completeBacklogRequests(board, { taskId });
|
|
2193
|
+
if (backlogged.length) (report.backlogged ??= []).push(...backlogged);
|
|
1806
2194
|
}
|
|
1807
2195
|
|
|
1808
2196
|
// When this process last read each run's turn (run id → seconds). Kept in memory on purpose: a board that restarted
|
|
1809
2197
|
// has read nothing itself, and its first reading of a run is where that run's watch resumes (resumedWatch).
|
|
1810
2198
|
const idleWatch = new Map();
|
|
2199
|
+
// When this process last read each run's turn as unreadable (run id → seconds): its unread clock (`unknown_since`)
|
|
2200
|
+
// runs only across rounds this process read without a break, whether or not the run's machine read online.
|
|
2201
|
+
const turnUnreadWatch = new Map();
|
|
1811
2202
|
/** Native idle intervals are independent of dispatch cadence and transcript writes.
|
|
1812
2203
|
* Keep bounded readings on the run so an ordinary quiet stretch can be reviewed later. */
|
|
2204
|
+
/**
|
|
2205
|
+
* The oldest message each running card's session on this machine has waiting undelivered, with its mailbox's reason
|
|
2206
|
+
* (`harness.v1.mail.waiting` for those mailboxes alone), by session address: `{ message_id, since_ms, attempts, reason }`,
|
|
2207
|
+
* or `{ unread }` when this machine's daemon did not answer. One call for the local sessions whose stall notice is due,
|
|
2208
|
+
* and none on an observation that has no such session.
|
|
2209
|
+
* Another machine's sessions add nothing: that door answers only its own machine's processes.
|
|
2210
|
+
*/
|
|
2211
|
+
async function undeliveredMail(board, running) {
|
|
2212
|
+
const here = localMachine(), addresses = new Set();
|
|
2213
|
+
for (const { id } of running) {
|
|
2214
|
+
const address = addressOf(currentRun(board.db, id)?.metadata?.supercode);
|
|
2215
|
+
if (address && address.split(':')[1] === here) addresses.add(address);
|
|
2216
|
+
}
|
|
2217
|
+
const out = new Map();
|
|
2218
|
+
if (!addresses.size) return out;
|
|
2219
|
+
const asked = [...addresses].slice(0, 256);
|
|
2220
|
+
for (const address of asked) out.set(address, null);
|
|
2221
|
+
for (const address of addresses) if (!out.has(address)) out.set(address, { unread: 'the due stall mailboxes exceeded the bounded mail read' });
|
|
2222
|
+
let rows;
|
|
2223
|
+
try {
|
|
2224
|
+
rows = (await machineCall(null, 'harness.v1.mail.waiting', { addresses: asked }, { timeoutMs: 5_000 }))?.rows;
|
|
2225
|
+
if (!Array.isArray(rows)) throw new Error('this machine answered without its waiting-mail rows');
|
|
2226
|
+
}
|
|
2227
|
+
catch (error) {
|
|
2228
|
+
const unread = String(error?.message ?? error).slice(0, 300);
|
|
2229
|
+
for (const address of addresses) out.set(address, { unread });
|
|
2230
|
+
return out;
|
|
2231
|
+
}
|
|
2232
|
+
const text = (value, bound) => typeof value === 'string' && value ? value.slice(0, bound) : null;
|
|
2233
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
2234
|
+
const to = text(row?.to, 512), message = text(row?.id, 128), reason = text(row?.last_error, 2_000);
|
|
2235
|
+
if (!to || !addresses.has(to) || !message || !reason || out.get(to) != null) continue;
|
|
2236
|
+
out.set(to, { message_id: message, since_ms: Number.isFinite(row.since_ms) ? row.since_ms : null,
|
|
2237
|
+
attempts: Number.isInteger(row.attempts) && row.attempts >= 0 ? row.attempts : 0, reason });
|
|
2238
|
+
}
|
|
2239
|
+
return out;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
1813
2242
|
async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
1814
2243
|
if (!fleet.ok) return;
|
|
1815
2244
|
// The published fleet says whether a session runs and its turn, not when it last wrote: that is the session's own
|
|
@@ -1817,84 +2246,284 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1817
2246
|
// Without it every reading was 'no_timestamp' and no session was ever found idle.
|
|
1818
2247
|
const updatedAt = (session) => {
|
|
1819
2248
|
let rows = null;
|
|
1820
|
-
try { rows = nativeSessions(session
|
|
2249
|
+
try { rows = nativeSessions(session); } catch { /* unread: no timestamp */ }
|
|
1821
2250
|
return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
|
|
1822
2251
|
};
|
|
1823
2252
|
const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
|
|
1824
|
-
const running = board.db.prepare(entry.workflow.run ?
|
|
1825
|
-
const
|
|
1826
|
-
|
|
2253
|
+
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();
|
|
2254
|
+
const idleSubject = (db, id) => {
|
|
2255
|
+
const run = currentRun(db, id);
|
|
2256
|
+
const session = run?.metadata?.supercode;
|
|
2257
|
+
if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null || run.ended_at != null) return null;
|
|
2258
|
+
const task = getTask(db, id), status = statusOf(db, task);
|
|
2259
|
+
const handles = name => entry.workflow.statuses[status]?.on?.[name] || entry.workflow.on?.[name] || entry.workflow.run?.on?.[name];
|
|
2260
|
+
if (!handles('session_idle') && !handles('session_stalled')) return null;
|
|
2261
|
+
const held = task.backlog || task.paused || task.blocked_by.length || task.archived || task.detached
|
|
2262
|
+
|| scopeOf(db, board, task, { params: entry.params, lazy: true }).card.dispatch_hold;
|
|
2263
|
+
return { run, session, task, status, handles, held };
|
|
2264
|
+
};
|
|
2265
|
+
const cardsOf = new Map(), retained = new Map(), sources = new Map(), listedSources = new Map();
|
|
2266
|
+
for (const row of fleet.sessions) {
|
|
2267
|
+
await yieldToOwner();
|
|
2268
|
+
if (stopping) return;
|
|
2269
|
+
const identity = homeIdentity({ ...row, native_home: locatorHome(row.locator) });
|
|
2270
|
+
const homes = listedSources.get(row.address) ?? new Set(); homes.add(identity); listedSources.set(row.address, homes);
|
|
2271
|
+
}
|
|
2272
|
+
for (const { id } of running) {
|
|
2273
|
+
await yieldToOwner();
|
|
2274
|
+
if (stopping) return;
|
|
2275
|
+
const session = view(board, db => currentRun(db, id)?.metadata?.supercode);
|
|
2276
|
+
if (!session) continue;
|
|
2277
|
+
await timed('idle native preparation', () => readBefore(fleet, session));
|
|
2278
|
+
if (stopping) return;
|
|
2279
|
+
const consumesCommits = view(board, db => {
|
|
2280
|
+
const subject = idleSubject(db, id);
|
|
2281
|
+
if (!subject || subject.held || !subject.handles('session_idle') || subject.session.role === 'reviewer') return false;
|
|
2282
|
+
const current = subject.session;
|
|
2283
|
+
if (current.machine && !fleet.online.has(current.machine) || current.surface === 'native' && sessionAlive(fleet, current) !== true) return false;
|
|
2284
|
+
return true;
|
|
2285
|
+
});
|
|
2286
|
+
if (consumesCommits) await timed('idle commit preparation', () => refreshCardInputs(board, id, { commits: 'cached' }));
|
|
2287
|
+
const address = addressOf(session);
|
|
2288
|
+
if (!address) continue;
|
|
2289
|
+
cardsOf.set(address, [...(cardsOf.get(address) ?? []), id]);
|
|
2290
|
+
retained.set(address, [...(retained.get(address) ?? []), session]);
|
|
2291
|
+
const identity = homeIdentity(session), prior = sources.get(address);
|
|
2292
|
+
sources.set(address, prior === undefined || prior === identity ? identity : null);
|
|
2293
|
+
}
|
|
2294
|
+
const edges = await timed('idle local delegations', () => localSessionDelegations(board.root, localMachine(), () => !stopping));
|
|
2295
|
+
if (stopping) return;
|
|
2296
|
+
for (const row of fleet.sessions) {
|
|
2297
|
+
await yieldToOwner();
|
|
2298
|
+
if (stopping) return;
|
|
2299
|
+
if (row.starter_session?.startsWith('sc:')) edges.push({ starter: row.starter_session, session: row.address });
|
|
2300
|
+
}
|
|
2301
|
+
for (const { id } of running) {
|
|
2302
|
+
await yieldToOwner();
|
|
2303
|
+
if (stopping) return;
|
|
2304
|
+
const edge = view(board, db => {
|
|
2305
|
+
const session = currentRun(db, id)?.metadata?.supercode;
|
|
2306
|
+
return session ? { starter: creatorOf(db, id)?.replace(/^session:/, ''), target: addressOf(session) } : null;
|
|
2307
|
+
});
|
|
2308
|
+
const { starter, target } = edge ?? {};
|
|
2309
|
+
if (starter?.startsWith('sc:') && target) edges.push({ starter, session: target });
|
|
2310
|
+
}
|
|
2311
|
+
const reached = new Set(retained.keys());
|
|
1827
2312
|
const delegations = [];
|
|
1828
2313
|
const pending = [...reached];
|
|
1829
2314
|
const byStarter = new Map();
|
|
1830
|
-
for (const edge of edges) {
|
|
2315
|
+
for (const edge of edges) {
|
|
2316
|
+
await yieldToOwner();
|
|
2317
|
+
if (stopping) return;
|
|
2318
|
+
const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children);
|
|
2319
|
+
}
|
|
1831
2320
|
while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
|
|
2321
|
+
await yieldToOwner();
|
|
2322
|
+
if (stopping) return;
|
|
1832
2323
|
delegations.push(edge);
|
|
1833
2324
|
if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
|
|
1834
2325
|
}
|
|
1835
2326
|
report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
|
|
1836
|
-
const delegatedReadings = [
|
|
2327
|
+
const delegatedReadings = [], delegatedReadAddresses = new Set();
|
|
1837
2328
|
// Forks and concurrent sessions need not have a pane. Read only the exact
|
|
1838
2329
|
// native identities this home's starter receipts name, before any write.
|
|
1839
2330
|
for (const { session: address } of delegations) {
|
|
1840
|
-
|
|
1841
|
-
|
|
2331
|
+
await yieldToOwner();
|
|
2332
|
+
if (stopping) return;
|
|
2333
|
+
if (delegatedReadAddresses.has(address)) continue;
|
|
2334
|
+
delegatedReadAddresses.add(address);
|
|
2335
|
+
const match = /^sc:([^:]+):([^:]+):(.+)$/.exec(address);
|
|
1842
2336
|
if (!match) continue;
|
|
1843
2337
|
const [, machine, harness, session_id] = match;
|
|
1844
|
-
|
|
1845
|
-
|
|
2338
|
+
if (!retained.has(address)) {
|
|
2339
|
+
const saved = await timed('idle delegated provenance', async () => {
|
|
2340
|
+
const revision = listRevision(board.db), sessions = [];
|
|
2341
|
+
let after = 0;
|
|
2342
|
+
for (;;) {
|
|
2343
|
+
const rows = board.db.prepare(`SELECT id,metadata FROM task_runs WHERE id IN (${NOTICE_RUN_IDS})
|
|
2344
|
+
AND id>? ORDER BY id LIMIT 64`).all(session_id, address, after);
|
|
2345
|
+
for (const row of rows) {
|
|
2346
|
+
const session = json(row.metadata, {})?.supercode;
|
|
2347
|
+
if (session && (session.address === address || session.machine === machine && session.session_id === session_id
|
|
2348
|
+
&& (session.harness ?? 'claude-code') === harness)) sessions.push(session);
|
|
2349
|
+
}
|
|
2350
|
+
if (rows.length < 64) break;
|
|
2351
|
+
after = rows.at(-1).id;
|
|
2352
|
+
await yieldToOwner();
|
|
2353
|
+
if (stopping) return null;
|
|
2354
|
+
}
|
|
2355
|
+
const current = listRevision(board.db);
|
|
2356
|
+
return Object.keys(revision).every(key => revision[key] === current[key]) ? sessions : null;
|
|
2357
|
+
});
|
|
2358
|
+
if (stopping) return;
|
|
2359
|
+
if (saved === null) { retained.set(address, []); sources.set(address, null); continue; }
|
|
2360
|
+
if (saved.length) {
|
|
2361
|
+
for (const session of saved) {
|
|
2362
|
+
await yieldToOwner();
|
|
2363
|
+
if (stopping) return;
|
|
2364
|
+
await timed('idle delegated native home', () => readNativeHome(session));
|
|
2365
|
+
}
|
|
2366
|
+
retained.set(address, saved);
|
|
2367
|
+
const homes = new Set(saved.map(homeIdentity));
|
|
2368
|
+
sources.set(address, homes.size === 1 ? [...homes][0] : null);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
if (!retained.has(address) && !['claude-code', 'codex'].includes(harness)) {
|
|
2372
|
+
const published = fleet.sessions.filter(row => row.address === address);
|
|
2373
|
+
const stores = new Set(published.map(row => row.locator?.storage ? JSON.stringify(row.locator.storage) : null));
|
|
2374
|
+
if (stores.size === 1 && !stores.has(null)) {
|
|
2375
|
+
sources.set(address, [...stores][0]); listedSources.set(address, stores);
|
|
2376
|
+
delegatedReadings.push(published.find(row => row.state === 'running' && row.turn === 'working') ?? published[0]);
|
|
2377
|
+
}
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
const child = retained.get(address)?.[0] ?? { machine, harness, session_id, surface: 'native' };
|
|
2381
|
+
if (retained.has(address) && !sources.get(address)) continue;
|
|
2382
|
+
if (!retained.has(address)) try {
|
|
2383
|
+
const rows = (await timed('idle delegated native home', () => readNative(machine, harness, session_id, null, { activity: false })))
|
|
2384
|
+
.filter(row => row.locator?.harness === harness && row.locator.session_id === session_id);
|
|
2385
|
+
const homes = new Map(rows.map(row => { const home = locatorHome(row.locator); return [JSON.stringify(home?.homes), home]; }));
|
|
2386
|
+
if (homes.size === 1) child.native_home = [...homes.values()][0];
|
|
2387
|
+
} catch { /* source unknown */ }
|
|
2388
|
+
await timed('idle delegated activity', () => readBefore(fleet, child));
|
|
2389
|
+
if (stopping) return;
|
|
2390
|
+
const identity = homeIdentity(child);
|
|
2391
|
+
if (!identity) continue;
|
|
2392
|
+
sources.set(address, identity);
|
|
2393
|
+
const published = fleetSessionRow(fleet, child);
|
|
2394
|
+
if (published) { delegatedReadings.push(published); continue; }
|
|
1846
2395
|
try {
|
|
1847
|
-
const { row } = nativeReading(nativeSessions(
|
|
2396
|
+
const { row } = nativeReading(nativeSessions(child), child);
|
|
1848
2397
|
if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
|
|
1849
2398
|
} catch { /* an unread child is never called working */ }
|
|
1850
2399
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
2400
|
+
const liveOf = (session) => {
|
|
2401
|
+
const published = fleetSessionRow(fleet, session);
|
|
2402
|
+
let nativeRow = null;
|
|
2403
|
+
if (!published && (session.surface === 'native' || sessionAlive(fleet, session) === true)) try { nativeRow = nativeReading(nativeSessions(session), session).row; } catch { /* unread: no turn */ }
|
|
2404
|
+
return published ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null, waiting_for: nativeRow.activity?.evidence?.waiting_for ?? null,
|
|
2405
|
+
turn_source: nativeRow.activity?.evidence?.source ?? null, turn_since_ms: nativeRow.activity?.evidence?.native_state_since_ms ?? null } : undefined);
|
|
2406
|
+
};
|
|
2407
|
+
const scopedAddress = target => sources.get(target) && (!listedSources.has(target)
|
|
2408
|
+
|| listedSources.get(target).size === 1 && listedSources.get(target).has(sources.get(target)));
|
|
2409
|
+
// Starter receipts name addresses; they cannot disambiguate an address observed in multiple stores.
|
|
2410
|
+
const scopedEdges = delegations.filter(edge => scopedAddress(edge.starter) && scopedAddress(edge.session));
|
|
2411
|
+
const delegatedOf = (session) => {
|
|
2412
|
+
const address = addressOf(session);
|
|
2413
|
+
if (!homeIdentity(session) || sources.get(address) !== homeIdentity(session)) return [];
|
|
2414
|
+
return delegatedWork(address, scopedEdges, delegatedReadings);
|
|
2415
|
+
};
|
|
2416
|
+
const unreadClock = (session, run, observedAt) => {
|
|
2417
|
+
const last = turnUnreadWatch.get(run.id);
|
|
2418
|
+
return last != null && observedAt - last <= Math.max(3 * settings.interval, settings.idleQuiet / 2) && Number(session.unknown_since) > 0 ? Number(session.unknown_since) : observedAt;
|
|
2419
|
+
};
|
|
2420
|
+
const resumedClock = (session, run, live, observedAt) => {
|
|
2421
|
+
const last = idleWatch.get(run.id);
|
|
2422
|
+
return last == null || observedAt - last > Math.max(3 * settings.interval, settings.idleQuiet / 2)
|
|
2423
|
+
? resumedWatch(session, live, run, fleet.linkedSince?.get(session.machine ?? localMachine()), observedAt) : null;
|
|
2424
|
+
};
|
|
2425
|
+
const noticeDue = (limits, quiet, prior, observedAt) => Number(limits.idle_notice) > 0 && quiet >= Number(limits.idle_notice)
|
|
2426
|
+
&& (!prior || (Number(limits.idle_notice_repeat) > 0 && observedAt - prior.at >= Number(limits.idle_notice_repeat)));
|
|
2427
|
+
// A mailbox read explains a due notice; it is not part of every idle reading.
|
|
2428
|
+
// Preview only the existing clock/eligibility rules, without writes. The
|
|
2429
|
+
// transaction below rechecks them after the one bounded door read.
|
|
2430
|
+
const dueMail = [];
|
|
2431
|
+
for (const row of running) {
|
|
2432
|
+
await yieldToOwner();
|
|
2433
|
+
if (stopping) return;
|
|
2434
|
+
const due = view(board, (db) => {
|
|
2435
|
+
const { id } = row;
|
|
2436
|
+
const subject = idleSubject(db, id);
|
|
2437
|
+
if (!subject || subject.held || !subject.handles('session_stalled') || addressOf(subject.session)?.split(':')[1] !== localMachine()) return false;
|
|
2438
|
+
const { task, run } = subject;
|
|
2439
|
+
let { session } = subject;
|
|
2440
|
+
const observedAt = now(), limits = limitsOf(db, board, entry, task, session);
|
|
2441
|
+
if (session.machine && !fleet.online.has(session.machine) || sessionAlive(fleet, session) !== true) return false;
|
|
2442
|
+
const live = liveOf(session);
|
|
2443
|
+
if (live?.turn !== 'idle') return false;
|
|
2444
|
+
const resumed = resumedClock(session, run, live, observedAt);
|
|
2445
|
+
if (resumed !== null) session = { ...session, watch_since: resumed };
|
|
2446
|
+
if (session.unknown_since != null) session = { ...session, watch_since: observedAt };
|
|
2447
|
+
const prior = session.idle_notice?.native_since_ms === (live?.turn_since_ms ?? null) ? session.idle_notice : null;
|
|
2448
|
+
const due = noticeDue(limits, idleInterval(session, live, observedAt).quiet, prior, observedAt);
|
|
2449
|
+
return due && !delegatedOf(session).length;
|
|
2450
|
+
});
|
|
2451
|
+
if (due) dueMail.push(row);
|
|
1857
2452
|
}
|
|
2453
|
+
const undelivered = await undeliveredMail(board, dueMail);
|
|
2454
|
+
const mailOf = (address) => undelivered.has(address) ? undelivered.get(address) : address?.split(':')[1] === localMachine()
|
|
2455
|
+
? { unread: 'the session became due after this observation selected its mailboxes' } : null;
|
|
1858
2456
|
for (const { id } of running) {
|
|
1859
|
-
|
|
2457
|
+
await yieldToOwner();
|
|
2458
|
+
if (stopping) return;
|
|
2459
|
+
let staged = [], watched = null, unread = null;
|
|
1860
2460
|
txRead(board, (db) => {
|
|
1861
|
-
staged = []; watched = null;
|
|
1862
|
-
const
|
|
1863
|
-
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
let session = runRow?.metadata?.supercode;
|
|
1868
|
-
if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
|
|
1869
|
-
if (task.paused || task.archived || task.detached || runRow.ended_at != null) {
|
|
2461
|
+
staged = []; watched = null; unread = null;
|
|
2462
|
+
const subject = idleSubject(db, id);
|
|
2463
|
+
if (!subject) return;
|
|
2464
|
+
const { task, status, handles, run: runRow } = subject;
|
|
2465
|
+
let { session } = subject;
|
|
2466
|
+
if (subject.held) {
|
|
1870
2467
|
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null, watch_since: now() });
|
|
1871
2468
|
return;
|
|
1872
2469
|
}
|
|
1873
|
-
//
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2470
|
+
// Unknown is an observation failure, never a workflow stall or work reminder (ADR 0073).
|
|
2471
|
+
const reportUnread = (observedAt, why, live = null) => {
|
|
2472
|
+
unread = [runRow.id, observedAt];
|
|
2473
|
+
const since = unreadClock(session, runRow, observedAt);
|
|
2474
|
+
if (session.unknown_since !== since) setRunSession(db, runRow.id, { unknown_since: since });
|
|
2475
|
+
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
|
|
2476
|
+
const quiet = Math.max(0, observedAt - since);
|
|
2477
|
+
const machineReadable = !session.machine || fleet.online.has(session.machine);
|
|
2478
|
+
const address = addressOf(session), identity = homeIdentity(session), key = JSON.stringify([address, identity, machineReadable]);
|
|
2479
|
+
if (quiet < settings.idleQuiet || session.unknown_notice?.key === key && session.unknown_notice.since === since) return;
|
|
2480
|
+
const sinceAt = new Date(since * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
2481
|
+
const observation = { decision: 'turn_unknown_reported', session: address, run_id: runRow.id, observed_at: observedAt, quiet_seconds: quiet, turn: live?.turn ?? null,
|
|
2482
|
+
turn_readable: false, unreadable_since: since, unreadable_since_at: sinceAt, unreadable_reason: why,
|
|
2483
|
+
idle_since: null, idle_since_at: null, last_turn_at: null, last_turn_at_ms: null, last_turn_time_source: null,
|
|
2484
|
+
status: live?.status ?? null, turn_source: live?.turn_source ?? null, waiting_for: live?.waiting_for ?? null, native_since_ms: live?.turn_since_ms ?? null };
|
|
2485
|
+
const told = identity && db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
|
|
2486
|
+
.some(r => {
|
|
2487
|
+
const other = json(r.metadata, {})?.supercode;
|
|
2488
|
+
return other?.unknown_notice?.key === key && other.unknown_since === since && other.unknown_notice.since === since;
|
|
2489
|
+
});
|
|
2490
|
+
setRunSession(db, runRow.id, { unknown_notice: { key, since, at: observedAt }, idle_observation: observation });
|
|
2491
|
+
event(db, id, 'session_turn_unknown', observation, runRow.id);
|
|
2492
|
+
const to = managers[0] ?? null;
|
|
2493
|
+
if (to && !told && machineReadable) {
|
|
2494
|
+
const others = (cardsOf.get(address) ?? []).filter(card => card !== id);
|
|
2495
|
+
event(db, id, 'board_message', { to, text: `${id}: its session ${address} cannot be read since ${sinceAt}: ${why}. Read status=${JSON.stringify(observation.status)}, turn=${JSON.stringify(observation.turn)}, source=${JSON.stringify(observation.turn_source)}, waiting_for=${JSON.stringify(observation.waiting_for)}, native_since_ms=${JSON.stringify(observation.native_since_ms)}.${others.length ? ` It is also on ${others.join(', ')}.` : ''} This is an unknown observation; it authorizes no stall, work reminder, reclaim or replacement.` }, null);
|
|
2496
|
+
}
|
|
2497
|
+
(report.held ??= []).push([id, 'turn_unknown', address]);
|
|
2498
|
+
};
|
|
2499
|
+
// A machine read offline is its run's machine wait (machine_offline), which speaks for it. One read but not current
|
|
2500
|
+
// (its record's panes or sessions unread) shows nothing of the session's turn: that turn is unreadable.
|
|
2501
|
+
if (session.machine && !fleet.online.has(session.machine)) {
|
|
2502
|
+
if (fleet.offline?.has(normalMachine(session.machine)) || sessionAlive(fleet, session) === false) return;
|
|
2503
|
+
const error = fleet.errors?.get(normalMachine(session.machine)) ?? fleet.error ?? null;
|
|
2504
|
+
reportUnread(now(), `machine ${session.machine} cannot be read${error ? `: ${String(error).split('\n')[0].slice(0, 300)}` : ''}`);
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
if (sessionAlive(fleet, session) !== true) {
|
|
2508
|
+
if (sessionAlive(fleet, session) === false) return;
|
|
2509
|
+
reportUnread(now(), `its session on ${session.machine ?? 'its machine'} could not be read`);
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
1877
2512
|
// A session the machine's record publishes no activity for has its turn on its machine's discovery row, which
|
|
1878
2513
|
// readBefore read: a session in no pane, and a live pane whose record names no session in it (a Codex pane on its
|
|
1879
2514
|
// own Codex home, VO-3259 run 2408: supervise read its turn idle there while this read none, so its run took the
|
|
1880
2515
|
// unknown-turn notice once and was never reminded or reclaimed). A pane is read so only while its record shows it.
|
|
1881
|
-
const
|
|
1882
|
-
let nativeRow = null;
|
|
1883
|
-
if (!published && (session.surface === 'native' || sessionAlive(fleet, session) === true)) try { nativeRow = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row; } catch { /* unread: no turn */ }
|
|
1884
|
-
const live = published ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null, waiting_for: nativeRow.activity?.evidence?.waiting_for ?? null,
|
|
1885
|
-
turn_source: nativeRow.activity?.evidence?.source ?? null, turn_since_ms: nativeRow.activity?.evidence?.native_state_since_ms ?? null } : undefined);
|
|
2516
|
+
const live = liveOf(session);
|
|
1886
2517
|
const observedAt = now();
|
|
1887
2518
|
// Quiet time is time the board watched. A run this process has not read for a while (the board restarted, was
|
|
1888
2519
|
// paused, could not read the fleet or the machine) resumes its idle clock where resumedWatch reads it: from the
|
|
1889
2520
|
// harness's own idle-since where its session reads idle with one and its machine's record was current over it,
|
|
1890
2521
|
// else from this reading, so a gap the board could not see is not idleness and no reminder is spent on it
|
|
1891
2522
|
// (volter-org t_9a9c2a9c: reminder 1 in the second its machine came back, the run reclaimed 45 minutes later).
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
setRunSession(db, runRow.id, { watch_since: resumed, unknown_since: null });
|
|
1897
|
-
session = { ...session, watch_since: resumed, unknown_since: null };
|
|
2523
|
+
const resumed = resumedClock(session, runRow, live, observedAt);
|
|
2524
|
+
if (resumed !== null) {
|
|
2525
|
+
setRunSession(db, runRow.id, { watch_since: resumed });
|
|
2526
|
+
session = { ...session, watch_since: resumed };
|
|
1898
2527
|
}
|
|
1899
2528
|
watched = [runRow.id, observedAt];
|
|
1900
2529
|
// A reviewer's work is its verdict, and its verdict ends its run: nothing the card shows while the review runs is
|
|
@@ -1933,15 +2562,12 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1933
2562
|
// put to its user is its harness's to say (`needs_input`); what it waits for is carried as it was read.
|
|
1934
2563
|
const address = addressOf(session);
|
|
1935
2564
|
const asked = live?.turn === 'needs_input';
|
|
1936
|
-
const
|
|
1937
|
-
for (const child of db.prepare('SELECT id,current_run_id FROM tasks WHERE current_run_id IS NOT NULL').all()) {
|
|
1938
|
-
const starter = creatorOf(db, child.id)?.replace(/^session:/, '');
|
|
1939
|
-
const target = addressOf(currentRun(db, child.id)?.metadata?.supercode);
|
|
1940
|
-
if (starter?.startsWith('sc:') && target) edges.push({ starter, session: target });
|
|
1941
|
-
}
|
|
1942
|
-
const delegated = delegatedWork(address, edges, delegatedReadings);
|
|
2565
|
+
const delegated = delegatedOf(session);
|
|
1943
2566
|
if (live?.turn === 'idle' || asked || live?.turn === 'working' || delegated.length) {
|
|
1944
|
-
if (session.unknown_since != null || session.unknown_notice)
|
|
2567
|
+
if (session.unknown_since != null || session.unknown_notice) {
|
|
2568
|
+
setRunSession(db, runRow.id, { unknown_since: null, unknown_notice: null, watch_since: observedAt });
|
|
2569
|
+
session = { ...session, watch_since: observedAt };
|
|
2570
|
+
}
|
|
1945
2571
|
}
|
|
1946
2572
|
// a reading that shows the session no longer waiting ends the prompt; a round that did not read it does not
|
|
1947
2573
|
if (session.held_notice && live && !asked) setRunSession(db, runRow.id, { held_notice: null });
|
|
@@ -1982,29 +2608,8 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1982
2608
|
retain(delegated.length ? 'delegated_work' : 'working', { delegated_sessions: delegated });
|
|
1983
2609
|
return;
|
|
1984
2610
|
}
|
|
1985
|
-
// An unread or unknown turn does not prove idle, but cannot remain silent
|
|
1986
|
-
// indefinitely. Bound the observed interval independently of transcript
|
|
1987
|
-
// writes, and tell the manager once with the native facts we actually read.
|
|
1988
2611
|
if (live?.turn !== 'idle') {
|
|
1989
|
-
|
|
1990
|
-
const since = Number(session.unknown_since ?? observedAt);
|
|
1991
|
-
reading.unknown_since = since;
|
|
1992
|
-
reading.quiet_seconds = Math.max(0, observedAt - since);
|
|
1993
|
-
if (session.unknown_since !== since) setRunSession(db, runRow.id, { unknown_since: since });
|
|
1994
|
-
if (reading.quiet_seconds < settings.idleQuiet) { retain('turn_unknown'); return; }
|
|
1995
|
-
if (session.unknown_notice) return;
|
|
1996
|
-
const key = [address, live?.turn_since_ms ?? 'unknown'].join('|');
|
|
1997
|
-
const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
|
|
1998
|
-
.some(r => json(r.metadata, {})?.supercode?.unknown_notice?.key === key);
|
|
1999
|
-
setRunSession(db, runRow.id, { unknown_notice: { key, at: observedAt, since } });
|
|
2000
|
-
const observation = retain('turn_unknown_reported', { waiting_for: live?.waiting_for ?? null, turn_source: live?.turn_source ?? null, since_ms: live?.turn_since_ms ?? null });
|
|
2001
|
-
event(db, id, 'session_turn_unknown', observation, runRow.id);
|
|
2002
|
-
const to = managers[0] ?? null;
|
|
2003
|
-
if (to && !told) {
|
|
2004
|
-
const others = (cardsOf.get(address) ?? []).filter(card => card !== id);
|
|
2005
|
-
event(db, id, 'board_message', { to, text: `${id}: its session ${address} has no known native turn after ${reading.quiet_seconds}s of observation (threshold ${settings.idleQuiet}s). Read status=${JSON.stringify(reading.status)}, turn=${JSON.stringify(reading.turn)}, source=${JSON.stringify(live?.turn_source ?? null)}, waiting_for=${JSON.stringify(live?.waiting_for ?? null)}, native_since_ms=${JSON.stringify(live?.turn_since_ms ?? null)}, last_message_at_ms=${JSON.stringify(reading.last_message_at_ms)}.${others.length ? ` It is also on ${others.join(', ')}.` : ''} Its liveness needs a decision; no idle state or permission answer was inferred.` }, null);
|
|
2006
|
-
}
|
|
2007
|
-
(report.held ??= []).push([id, 'turn_unknown', address]);
|
|
2612
|
+
reportUnread(observedAt, 'its session has no known native turn', live);
|
|
2008
2613
|
return;
|
|
2009
2614
|
}
|
|
2010
2615
|
// A native idle receipt also works without a transcript timestamp.
|
|
@@ -2019,15 +2624,15 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
2019
2624
|
let stalled = false;
|
|
2020
2625
|
if (handles('session_stalled')) {
|
|
2021
2626
|
const limits = limitsOf(db, board, entry, task, session);
|
|
2022
|
-
const first = Number(limits.idle_notice), repeat = Number(limits.idle_notice_repeat);
|
|
2023
2627
|
const nativeIdle = live?.turn_since_ms ?? null;
|
|
2024
2628
|
const prior = session.idle_notice?.native_since_ms === nativeIdle ? session.idle_notice : null;
|
|
2025
|
-
const due =
|
|
2629
|
+
const due = noticeDue(limits, interval.quiet, prior, observedAt);
|
|
2026
2630
|
if (due) try {
|
|
2027
2631
|
const turnAt = Number(live?.turn_since_ms) || (lastAt > 0 ? Number(lastAtMs) : null);
|
|
2028
2632
|
const payload = { ...reading, session: address, run_id: runRow.id, last_turn_at_ms: turnAt,
|
|
2029
2633
|
last_turn_at: turnAt ? new Date(turnAt).toISOString() : null,
|
|
2030
|
-
last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior)
|
|
2634
|
+
last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior),
|
|
2635
|
+
undelivered: mailOf(address) };
|
|
2031
2636
|
const result = fire(db, board, entry, id, 'session_stalled', { roles: ['platform'], payload });
|
|
2032
2637
|
staged.push(...result.effects);
|
|
2033
2638
|
stalled = true;
|
|
@@ -2053,7 +2658,12 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
2053
2658
|
const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface !== 'headless' ? 'pane' : 'headless');
|
|
2054
2659
|
const brief = pendingBrief(board, task, runRow.id, where);
|
|
2055
2660
|
const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { ...reading, turns: reading.turns_before, worked }, extra: { where, doors: said, pending_brief: brief } });
|
|
2056
|
-
// a reminder wakes the session with what its run is for: send_to_actor saves it with the run's pending brief
|
|
2661
|
+
// a reminder wakes the session with what its run is for: send_to_actor saves it with the run's pending brief, and
|
|
2662
|
+
// one that carries the brief is filed as the run's instruction, as the brief itself is, never as a notice
|
|
2663
|
+
if (brief) for (const effect of r.effects) if (effect.kind === 'send_to_actor' && effect.durableId) {
|
|
2664
|
+
effect.as = 'instruction';
|
|
2665
|
+
db.prepare("UPDATE supercode_effects SET effect = json_set(effect, '$.as', 'instruction') WHERE id = ?").run(effect.durableId);
|
|
2666
|
+
}
|
|
2057
2667
|
staged.push(...r.effects);
|
|
2058
2668
|
const after = currentRun(db, id);
|
|
2059
2669
|
const stored = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runRow.id)?.metadata, {})?.supercode;
|
|
@@ -2080,9 +2690,10 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
2080
2690
|
}
|
|
2081
2691
|
});
|
|
2082
2692
|
if (watched) idleWatch.set(...watched);
|
|
2693
|
+
if (unread) turnUnreadWatch.set(...unread);
|
|
2083
2694
|
effects.push(...staged);
|
|
2084
2695
|
}
|
|
2085
|
-
for (const [runId, at] of
|
|
2696
|
+
for (const watch of [idleWatch, turnUnreadWatch]) for (const [runId, at] of watch) if (now() - at > 86_400) watch.delete(runId);
|
|
2086
2697
|
}
|
|
2087
2698
|
|
|
2088
2699
|
function effectMessageKey(effect) {
|
|
@@ -2110,11 +2721,13 @@ export async function sessionNotice(board, e) {
|
|
|
2110
2721
|
if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
2111
2722
|
const card = view(board, (db) => getTask(db, e.id));
|
|
2112
2723
|
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}`, noticeName(card));
|
|
2113
|
-
|
|
2724
|
+
// a role's brief (queueBrief: an adopted session's, or one told again when its prompt changed) is the run's instruction
|
|
2725
|
+
const as = ['brief', 'brief_changed'].includes(e.verb) ? 'instruction' : 'notice';
|
|
2726
|
+
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { as, subject });
|
|
2114
2727
|
tx(board, (db) => {
|
|
2115
2728
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
2116
2729
|
if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
2117
|
-
if (filed.detached) event(db, e.id, 'session_notice_not_required', { address, notice: e.durableId, reason: 'session detached to a person' }, e.runId);
|
|
2730
|
+
if (filed.detached) event(db, e.id, 'session_notice_not_required', { address, verb: e.verb, notice: e.durableId, reason: 'session detached to a person' }, e.runId);
|
|
2118
2731
|
if (filed.new) event(db, e.id, 'session_notice_filed', { address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, message_id: filed.message_id }, e.runId);
|
|
2119
2732
|
});
|
|
2120
2733
|
return true;
|
|
@@ -2132,7 +2745,7 @@ export async function sessionEffect(board, e) {
|
|
|
2132
2745
|
if (!address) throw new Error('actor session identity unavailable');
|
|
2133
2746
|
// filed in the actor's mailbox, which delivers it
|
|
2134
2747
|
const card = view(board, (db) => getTask(db, e.id));
|
|
2135
|
-
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}`, noticeName(card)) });
|
|
2748
|
+
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { as: e.as === 'instruction' ? 'instruction' : 'notice', subject: numbered(boardKey(board.root, board.slug), card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`, noticeName(card)) });
|
|
2136
2749
|
if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
|
|
2137
2750
|
return true;
|
|
2138
2751
|
}
|
|
@@ -2151,7 +2764,7 @@ export async function sessionEffect(board, e) {
|
|
|
2151
2764
|
*/
|
|
2152
2765
|
const MERGE_BUDGET_MS = 300_000;
|
|
2153
2766
|
export async function mergeReviewed(board, e) {
|
|
2154
|
-
const { candidatePullRequests, mergeReviewedPullRequest } = await import('./ci.mjs');
|
|
2767
|
+
const { candidatePullRequests, mergeReviewedPullRequest, verifiedReleaseReadback } = await import('./ci.mjs');
|
|
2155
2768
|
const pass = view(board, (db) => db.prepare("SELECT id, payload FROM task_events WHERE task_id = ? AND kind = 'review_pass' ORDER BY id DESC LIMIT 1").get(e.id));
|
|
2156
2769
|
const recorded = json(pass?.payload, {})?.ci ?? null;
|
|
2157
2770
|
const ci = typeof recorded === 'string' ? json(recorded, null) : recorded;
|
|
@@ -2161,6 +2774,14 @@ export async function mergeReviewed(board, e) {
|
|
|
2161
2774
|
// effect's claim; what is left is the next attempt's
|
|
2162
2775
|
const began = Date.now();
|
|
2163
2776
|
for (const p of prs) {
|
|
2777
|
+
if (prDisposition(board,e.id,p.pr.url,p.commit).disposition === 'release-only') {
|
|
2778
|
+
// This head must never reach the merge door. Settlement independently rechecks its
|
|
2779
|
+
// current PR head and the retained disposition/verdict/readback binding.
|
|
2780
|
+
const readback = await verifiedReleaseReadback(board,e.id,p.pr.url,p.commit);
|
|
2781
|
+
if (!readback) return false;
|
|
2782
|
+
merges.push({ pr: p.pr.url, reviewed: p.commit, release_only: true, target: readback.target, artifact: readback.artifact });
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2164
2785
|
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`);
|
|
2165
2786
|
let answer;
|
|
2166
2787
|
try { answer = await mergeReviewedPullRequest(p.pr.url, p.commit); } catch (error) {
|
|
@@ -2183,10 +2804,12 @@ export async function mergeReviewed(board, e) {
|
|
|
2183
2804
|
// A fault other than the card's refusal keeps the effect for the next round, and the round goes on past it.
|
|
2184
2805
|
try { tx(board, (db) => {
|
|
2185
2806
|
event(db, e.id, 'reviewed_merged', { merges });
|
|
2807
|
+
// a closed card's kept worktree, or a closed subtask's, is read again now that its merge has landed
|
|
2808
|
+
for (const row of db.prepare("SELECT t.id FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE (t.id = ? OR c.subtask_of = ?) AND t.status IN ('done', 'cancelled', 'archived') AND t.workspace_kind = 'worktree'").all(e.id, e.id)) queueCleanup(db, row.id);
|
|
2186
2809
|
// A card that moved after its pass cannot take the event: that is said to the manager once, never thrown, so the
|
|
2187
2810
|
// kept effect does not stop every round after it (as merge_refused, above).
|
|
2188
2811
|
let r;
|
|
2189
|
-
try { r = fire(db, board, entry, e.id, 'reviewed_merged', { roles: ['platform'], payload: { merges, summary: merges.length ? `
|
|
2812
|
+
try { r = fire(db, board, entry, e.id, 'reviewed_merged', { roles: ['platform'], payload: { merges, summary: merges.length ? `Settled at the reviewed heads: ${merges.map((m) => m.release_only ? `${m.pr} (released ${m.artifact} on ${m.target})` : `${m.pr} (${m.reviewed.slice(0, 8)} → ${String(m.merge).slice(0, 8)})`).join(', ')}` : 'The pass named no PR to merge.' } }); }
|
|
2190
2813
|
catch (error) {
|
|
2191
2814
|
// only the card's refusal is said and consumed; anything else (a write contention, a fault) is thrown and retried
|
|
2192
2815
|
if (!error.noTransition && !error.refusal && !/does not apply|there is for/.test(error.message)) throw error;
|
|
@@ -2262,13 +2885,10 @@ function noteGuard(db, id, reason) {
|
|
|
2262
2885
|
if (guardNoted(db, id, reason)) return;
|
|
2263
2886
|
event(db, id, 'respawn_guarded', { reason });
|
|
2264
2887
|
}
|
|
2265
|
-
/** Whether `reason` is already noted
|
|
2266
|
-
*
|
|
2267
|
-
* is opened too: a card that waits for the same reason round after round costs no transaction. */
|
|
2888
|
+
/** Whether `reason` is already noted since this stage entry and latest run. Read before a write is opened too:
|
|
2889
|
+
* a card that waits for the same reason round after round costs no transaction. */
|
|
2268
2890
|
function guardNoted(db, id, reason) {
|
|
2269
|
-
|
|
2270
|
-
const last = db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('respawn_guarded', 'claimed') AND created_at >= ? ORDER BY id DESC LIMIT 1").get(id, since);
|
|
2271
|
-
return json(last?.payload, {})?.reason === reason;
|
|
2891
|
+
return dispatchWait(db, id)?.reason === reason;
|
|
2272
2892
|
}
|
|
2273
2893
|
|
|
2274
2894
|
/** Hermes's emergency stop: the `ESTOP` sentinel in the home or in `~/.hermes` stops new starts (running ones go on). */
|
|
@@ -2286,14 +2906,15 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
|
|
|
2286
2906
|
|
|
2287
2907
|
/** A transport error is not a negative launch receipt. The key remains owned by its run. */
|
|
2288
2908
|
/**
|
|
2289
|
-
*
|
|
2290
|
-
* session with its id, 'absent' when its machine answered and lists neither, null when its machine was not read.
|
|
2909
|
+
* A bound launch's scoped conversation presence, or its pane presence before binding. Unread source/activity is null.
|
|
2291
2910
|
*/
|
|
2292
2911
|
function launchReading(fleet, session) {
|
|
2293
2912
|
const machine = normalMachine(session?.machine ?? localMachine());
|
|
2294
2913
|
if (!fleet?.ok || !fleet.online?.has(machine) || !fleet.panes?.has(machine)) return null;
|
|
2295
|
-
|
|
2296
|
-
|
|
2914
|
+
if (!session.session_id) return session.pane && fleet.panes.get(machine).has(session.pane) ? 'present' : 'absent';
|
|
2915
|
+
if (!nativeHome(session)) return null;
|
|
2916
|
+
const alive = sessionAlive(fleet, session);
|
|
2917
|
+
return alive === true ? 'present' : alive === false ? 'absent' : null;
|
|
2297
2918
|
}
|
|
2298
2919
|
|
|
2299
2920
|
/** How a keyed launch ended, as its machine recorded it; read only where the machine has the door (supervise). */
|
|
@@ -2307,21 +2928,67 @@ async function launchReceipt(board, session, timeout = 30_000) {
|
|
|
2307
2928
|
return receipt;
|
|
2308
2929
|
}
|
|
2309
2930
|
|
|
2310
|
-
|
|
2931
|
+
/** Retire only a fresh attempt the machine positively records as never issued; its intended id is no conversation. */
|
|
2932
|
+
async function retireUnissuedSlotLaunch(board, taskId, runId, slot) {
|
|
2933
|
+
for (;;) {
|
|
2934
|
+
const prior = view(board, db => establishedSlot(db, taskId, slot, runId));
|
|
2935
|
+
const session = prior?.session;
|
|
2936
|
+
if (!session?.launch_key || session.mode !== 'fresh' || session.surface !== 'pane' || session.address || session.seen_at) return;
|
|
2937
|
+
const result = await doorOn(session.machine, 'harness.v1.launches.cancel', { key: session.launch_key },
|
|
2938
|
+
{ timeout: 5_000, as: boardAddress(board.label) });
|
|
2939
|
+
const receipt = result.code === 0 ? lastJson(result.stdout) : null;
|
|
2940
|
+
if (receipt?.key !== session.launch_key || receipt.by !== boardAddress(board.label) || receipt.status !== 'cancelled'
|
|
2941
|
+
|| receipt.outcome !== 'not_issued' || receipt.pane || receipt.native_session || receipt.live !== false) return;
|
|
2942
|
+
const retired = tx(board, db => {
|
|
2943
|
+
if (!runCurrent(db, taskId, runId)) throw superseded(taskId, runId);
|
|
2944
|
+
const current = establishedSlot(db, taskId, slot, runId);
|
|
2945
|
+
if (current?.run !== prior.run || current.session.launch_key !== session.launch_key || current.session.address || current.session.seen_at) return false;
|
|
2946
|
+
setRunSession(db, prior.run, { launch_not_issued: true, launch_pending: !current.session.close_confirmed_at });
|
|
2947
|
+
requestSessionClose(db, taskId, { runId: prior.run });
|
|
2948
|
+
event(db, taskId, 'session_launch_not_issued', { key: session.launch_key, machine: session.machine,
|
|
2949
|
+
intended_session: session.session_id ?? null, receipt }, prior.run);
|
|
2950
|
+
return true;
|
|
2951
|
+
});
|
|
2952
|
+
if (!retired) return;
|
|
2953
|
+
await closeRequested(board, { undelivered: [] }, { taskId });
|
|
2954
|
+
if (view(board, db => establishedSlot(db, taskId, slot, runId))?.run === prior.run) return;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
function bindLaunch(board, row, session, receipt, { refile = null } = {}) {
|
|
2959
|
+
if (receipt?.key !== session.launch_key || receipt.by !== boardAddress(board.label) || receipt.pane !== session.pane
|
|
2960
|
+
|| (refile ? receipt.inputBounced !== true : receipt.inputDelivered !== true || receipt.inputReceipt !== 'native-user-message')
|
|
2961
|
+
|| typeof receipt.native_session !== 'string' || !receipt.native_session) return false;
|
|
2962
|
+
const pane = receipt.pane, sid = receipt.native_session;
|
|
2963
|
+
const home = receipt.native_home ?? nativeHome(session);
|
|
2964
|
+
if (nativeHome(session) && home && JSON.stringify(nativeHome(session).homes) !== JSON.stringify(home.homes))
|
|
2965
|
+
throw unavailable(`launch ${session.launch_key} returned a different native home; its identity was not bound`);
|
|
2311
2966
|
return tx(board, (db) => {
|
|
2312
2967
|
const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
|
|
2313
2968
|
const saved = json(current?.metadata, {})?.supercode;
|
|
2314
2969
|
// a launch another round already bound, or whose session was closed, is not bound again
|
|
2315
2970
|
if (!saved || saved.launch_key !== session.launch_key || !saved.launch_pending || saved.close_confirmed_at || (saved.session_id && sid && saved.session_id !== sid)) return false;
|
|
2316
|
-
const
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2971
|
+
const task = getTask(db, row.task_id);
|
|
2972
|
+
let refused = null;
|
|
2973
|
+
if (!current.ended_at && task?.current_run_id === row.id && !task.backlog) {
|
|
2974
|
+
try { assertHeadlessBinding(board, row.task_id, row.id, saved.harness, sid); }
|
|
2975
|
+
catch (error) { refused = error; }
|
|
2976
|
+
}
|
|
2977
|
+
if (nativeHome(saved) && home && JSON.stringify(nativeHome(saved).homes) !== JSON.stringify(home.homes))
|
|
2978
|
+
throw unavailable(`launch ${session.launch_key} changed its retained native home before binding`);
|
|
2979
|
+
const address = `sc:${session.machine}:${session.harness}:${sid}`;
|
|
2980
|
+
setRunSession(db, row.id, { ...saved, pane, session_id: sid, native_home: home,
|
|
2981
|
+
address, launch_pending: false, launch_error: null, seen_at: now() });
|
|
2982
|
+
if (current.ended_at || task?.backlog || refused) requestSessionClose(db, row.task_id, { runId: row.id });
|
|
2983
|
+
if (refused) {
|
|
2984
|
+
setRunSession(db, row.id, { continuation_required: true });
|
|
2985
|
+
surfaceIncident(db, board, workflowFor(board.root), row.task_id, 'spawn_failed', { error: refused.message, origin: originOf(refused) }, row.id);
|
|
2986
|
+
} else if (!task?.backlog && getTask(db, row.task_id)?.current_run_id === row.id) {
|
|
2321
2987
|
replaceWorkerSubscriptions(db, row.task_id, address);
|
|
2322
2988
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id=?').all(row.task_id)) subscribeIn(db, p.parent_id, address, 'dependency');
|
|
2323
2989
|
}
|
|
2324
|
-
|
|
2990
|
+
event(db, row.task_id, 'session_launch_reconciled', { key: session.launch_key, pane, session_id: sid, role: saved.role }, row.id);
|
|
2991
|
+
if (refile && !current.ended_at && !refused && getTask(db, row.task_id)?.current_run_id === row.id) refile(db, address);
|
|
2325
2992
|
return true;
|
|
2326
2993
|
});
|
|
2327
2994
|
}
|
|
@@ -2338,12 +3005,11 @@ export async function reconcileCallerLaunch(board, sid) {
|
|
|
2338
3005
|
return s?.machine === machine && s.harness === harness && (!s.session_id || s.session_id === sid);
|
|
2339
3006
|
});
|
|
2340
3007
|
if (!candidates.length) return;
|
|
2341
|
-
const pane = process.env.SUPERCODE_TEAMS_PANE || (await observeFleet()).sessions?.find((s) => s.address === `sc:${machine}:${harness}:${sid}`)?.pane;
|
|
2342
|
-
if (!pane) return;
|
|
2343
|
-
// the caller's own pane (its environment, or this machine's reading) binds the run whose launch recorded that pane
|
|
2344
3008
|
for (const row of candidates) {
|
|
2345
3009
|
const s = json(row.metadata, {})?.supercode;
|
|
2346
|
-
|
|
3010
|
+
await readNativeHome(s);
|
|
3011
|
+
const receipt = await launchReceipt(board, s, 5000);
|
|
3012
|
+
if (receipt?.native_session === sid && bindLaunch(board, row, s, receipt)) return;
|
|
2347
3013
|
}
|
|
2348
3014
|
}
|
|
2349
3015
|
|
|
@@ -2355,11 +3021,13 @@ const SESSION_UNREADABLE=-32050;
|
|
|
2355
3021
|
* file's size and time here, what discovery says of it on another machine), and asked again once either differs: the
|
|
2356
3022
|
* same bytes get the same answer only from the same reader. Every other failure (no answer, a timeout, a fault in the
|
|
2357
3023
|
* door, a daemon that does not say which it is) says nothing of the record, and the read is made again. */
|
|
2358
|
-
async function readRunUsage(root,org,row,endedAt){
|
|
3024
|
+
async function readRunUsage(root,org,row,endedAt,session){
|
|
2359
3025
|
const [harness,...rest]=row.session_ref.split(':'),machine=row.machine&&normalMachine(row.machine)!==localMachine()?row.machine:null;
|
|
3026
|
+
const home = await readNativeHome(session);
|
|
3027
|
+
if (!home) return;
|
|
2360
3028
|
// the usage door loads the session by its whole locator, which discovery names
|
|
2361
|
-
const sessionId=rest.join(':'),found=await runtimeDoor(machine,'harness.v1.sessions.discover',{query:sessionId,harnesses:[harness],limit:200},{timeout:30_000});
|
|
2362
|
-
const descriptor=found.code===0?json(found.stdout.trim(),{})?.sessions?.find(item=>item.locator?.harness===harness&&item.locator?.session_id===sessionId):null,locator=descriptor?.locator??null;
|
|
3029
|
+
const sessionId=rest.join(':'),found=await runtimeDoor(machine,'harness.v1.sessions.discover',{query:sessionId,harnesses:[harness],homes:home.homes,limit:200,include_activity:false},{timeout:30_000});
|
|
3030
|
+
const descriptor=found.code===0?json(found.stdout.trim(),{})?.sessions?.find(item=>item.locator?.harness===harness&&item.locator?.session_id===sessionId&&inNativeHome(item,home.homes)):null,locator=descriptor?.locator??null;
|
|
2363
3031
|
let stamp=null;
|
|
2364
3032
|
if(locator){
|
|
2365
3033
|
const described=await runtimeDoor(machine,'harness.v1.machine.describe',{},{timeout:15_000}),reader=described.code===0?json(described.stdout.trim(),{}):null;
|
|
@@ -2378,19 +3046,21 @@ async function readRunUsage(root,org,row,endedAt){
|
|
|
2378
3046
|
* handed to this machine's daemon, and usage read from each machine's own doors. No Teams call: the daemon declares and
|
|
2379
3047
|
* ends agents and binds their sessions (D137). It runs in the delivery pass (`deliver`), never in a dispatch round. */
|
|
2380
3048
|
async function synchronizeAgents(board, report, fleet = null) {
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
const
|
|
2386
|
-
|
|
3049
|
+
const work = await currentAgentWork(board);
|
|
3050
|
+
const live = new Map();
|
|
3051
|
+
// Credential decisions depend on fleet/custody too, but only current retained credentials
|
|
3052
|
+
// enter this pass; settled ended history does not.
|
|
3053
|
+
for (const ref of work.credentials) {
|
|
3054
|
+
const row = work.rows.get(ref), session = row?.session;
|
|
3055
|
+
if (row && credentialsWanted(board,row,session,fleet)) live.set(credentialsKey(session.machine,session.agent_id),{task:row.task_id,run:row.id});
|
|
3056
|
+
}
|
|
3057
|
+
for (const key of [...work.pending]) {
|
|
2387
3058
|
await yieldToOwner();
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
const
|
|
2391
|
-
if(
|
|
2392
|
-
|
|
2393
|
-
const ref=`${session.harness}:${session.session_id}`;
|
|
3059
|
+
// A yielded projection is advisory. Resolve its current latest identity again before
|
|
3060
|
+
// recording ownership, just as launch/close effects reread their target.
|
|
3061
|
+
const row = latestAgentRow(board,key), session = row?.session;
|
|
3062
|
+
if (!row || !session?.session_id || !session.agent_org || view(board,db => detachedSession(db,session))) { work.pending.delete(key); continue; }
|
|
3063
|
+
const ref = `${session.harness}:${session.session_id}`;
|
|
2394
3064
|
// An ended run whose session the agent store already holds for another agent lost that claim when it ran: no round
|
|
2395
3065
|
// can change an ended run, so the disagreement is history. It is said once on its card (agent_ownership_conflict)
|
|
2396
3066
|
// and the run is not recorded again. A live run is never history: it is recorded below every round, and a conflict
|
|
@@ -2400,31 +3070,40 @@ async function synchronizeAgents(board, report, fleet = null) {
|
|
|
2400
3070
|
if(owner&&owner.org===session.agent_org&&owner.agent_id!==session.agent_id){
|
|
2401
3071
|
const said=db=>db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='agent_ownership_conflict' LIMIT 1").get(row.task_id,row.id);
|
|
2402
3072
|
if(!view(board,said))tx(board,db=>{if(!said(db))event(db,row.task_id,'agent_ownership_conflict',{session:ref,run_agent:session.agent_id,owner:owner.agent_id,owner_run:owner.run_id},row.id);});
|
|
2403
|
-
continue;
|
|
3073
|
+
work.pending.delete(key); continue;
|
|
2404
3074
|
}
|
|
2405
3075
|
}
|
|
2406
3076
|
// One run whose session another agent already owns is that card's problem, said on it; the round goes on for the rest.
|
|
2407
3077
|
try{recordAgentRun(board.root,session.agent_org,session.agent_id,{session:ref,machine:session.machine,run:row.id});}
|
|
2408
3078
|
catch(error){report.queued.push([row.task_id,`run ${row.id}: ${error.message}`]);continue;}
|
|
3079
|
+
work.pending.delete(key);
|
|
2409
3080
|
}
|
|
2410
3081
|
await keepAgentCredentialsCurrent(board,live,report);
|
|
2411
|
-
//
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
3082
|
+
// Outstanding usage remains due work. A refused reader is retried at a bounded cadence,
|
|
3083
|
+
// independently of new events, instead of rediscovering settled history each idle pass.
|
|
3084
|
+
const runsById = new Map([...work.rows.values()].map(row => [String(row.id),row]));
|
|
3085
|
+
for (const org of pendingUsageOrgs(board.root)) {
|
|
3086
|
+
let read = 0;
|
|
3087
|
+
for (const row of runsAwaitingUsage(board.root,org)) {
|
|
3088
|
+
const run = runsById.get(String(row.run_id)), key = `${org}\0${row.session_ref}`;
|
|
3089
|
+
if (!run?.ended_at || read >= 5 || (work.usageDue.get(key) ?? 0) > Date.now()
|
|
3090
|
+
|| view(board,db => detachedSession(db,run.session))) continue;
|
|
3091
|
+
// Failed calls remain pending in the authoritative agent store, including across restart.
|
|
3092
|
+
work.usageDue.set(key,Date.now()+60_000);
|
|
3093
|
+
await readRunUsage(board.root,org,row,run.ended_at,run.session); read++;
|
|
2418
3094
|
}
|
|
2419
3095
|
}
|
|
2420
|
-
for(const
|
|
3096
|
+
for (const target of [...work.assignments]) {
|
|
2421
3097
|
await yieldToOwner();
|
|
2422
|
-
const task=getTaskNow(board,
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
if(task
|
|
2426
|
-
|
|
3098
|
+
const task = getTaskNow(board,target);
|
|
3099
|
+
for (const assignment of cardAgentAssignments(board.root,target)) {
|
|
3100
|
+
if (task && (assignment.state==='ended' || task.archived || ['done','cancelled'].includes(task.status))) finishCardAgentAssignment(board,assignment,`card ${target} ${task.archived?'archived':task.status}`);
|
|
3101
|
+
else if (task) {
|
|
3102
|
+
if (task.readable_id) renameCardAgent(board.root,assignment,task.readable_id);
|
|
3103
|
+
recordCardAgentAssignment(board,assignment);
|
|
3104
|
+
}
|
|
2427
3105
|
}
|
|
3106
|
+
work.assignments.delete(target);
|
|
2428
3107
|
}
|
|
2429
3108
|
// What a creating or closing verb could not hand on (no daemon running then) is handed on here.
|
|
2430
3109
|
try{for(const pending of await handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
|
|
@@ -2434,22 +3113,33 @@ async function synchronizeAgents(board, report, fleet = null) {
|
|
|
2434
3113
|
async function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes) {
|
|
2435
3114
|
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());
|
|
2436
3115
|
// A deferred launch was never issued (a reading or door was not yet available): it is the start its manager, creator
|
|
2437
|
-
// or workflow asked for, carried out within the launch window
|
|
2438
|
-
//
|
|
2439
|
-
//
|
|
3116
|
+
// or workflow asked for, carried out within the launch window that began when the run started; supervise surfaces it
|
|
3117
|
+
// once when the window ends. A round that cannot carry it out says why, reads nothing for it and leaves that clock
|
|
3118
|
+
// where it is: a wait never restarts the window.
|
|
2440
3119
|
for(const {id,run_id,launched_at} of deferred) {
|
|
3120
|
+
await yieldToOwner();
|
|
3121
|
+
if (stopping) return;
|
|
2441
3122
|
if (now() - launched_at >= settings.launchWindow) continue;
|
|
2442
3123
|
// the run's launch is claimed before anything is done for it: another round's claim, or its launch, skips it here
|
|
2443
|
-
const stillDeferred=
|
|
2444
|
-
|
|
3124
|
+
const stillDeferred = db => {
|
|
3125
|
+
const run = currentRun(db, id), session = run?.metadata?.supercode;
|
|
3126
|
+
return run?.id === run_id && session?.launch_deferred && !getTask(db, id)?.backlog ? session : null;
|
|
3127
|
+
};
|
|
3128
|
+
const wait = reason => {
|
|
3129
|
+
const needsWait = db => Boolean(stillDeferred(db) && stillDeferred(db).launch_deferred_error !== reason);
|
|
3130
|
+
if (view(board, needsWait)) tx(board, db => { if (needsWait(db)) setRunSession(db, run_id, { launch_deferred_error: reason }); });
|
|
3131
|
+
report.queued.push([id, reason]);
|
|
3132
|
+
};
|
|
2445
3133
|
try {
|
|
2446
3134
|
const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
3135
|
+
if (task.backlog) { report.queued.push([id, 'backlog: its deferred launch is not admitted']); continue; }
|
|
3136
|
+
assertRunCurrent(board,id,run_id);
|
|
3137
|
+
const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params,lazy:true})));
|
|
3138
|
+
const lane=lanes?.[profile];if(!lane){wait('launch waits for its configured lane');continue;}
|
|
3139
|
+
if (lane.surface === 'pane' && !fleet.ok) { wait(`launch waits for the fleet: ${fleet.error}`); continue; }
|
|
2450
3140
|
const host=lane.surface==='pane'&&task.machine?executionHost(lane,task.machine,localMachine(),normalMachine):null;
|
|
2451
|
-
if(host&&fleet.unknown?.has(host)){
|
|
2452
|
-
const why=launcherCheck(board.db,task,lane,fleet);if(why){
|
|
3141
|
+
if(host&&fleet.unknown?.has(host)){wait(`launch waits for machine ${host}'s fleet record: ${fleet.errors?.get(host)??'no current record'}`);continue;}
|
|
3142
|
+
const why=launcherCheck(board.db,task,lane,fleet);if(why){wait(why);continue;}
|
|
2453
3143
|
const claimed=tx(board,db=>{if(!stillDeferred(db))return false;setRunSession(db,run_id,{launch_deferred:false});return true;});
|
|
2454
3144
|
if(!claimed){report.queued.push([id,'its launch was taken by another round']);continue;}
|
|
2455
3145
|
const session=await launchRun(board,entry,id,lane,fleet,settings);
|
|
@@ -2467,27 +3157,15 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2467
3157
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
|
|
2468
3158
|
WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
|
|
2469
3159
|
AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
|
|
2470
|
-
// A keyed launch is bound from
|
|
3160
|
+
// A keyed launch is bound only from its machine's exact native first-input receipt. A launch with
|
|
2471
3161
|
// no pane waits for its launch window (supervise surfaces it then); a pane read absent on its online machine after
|
|
2472
3162
|
// the grace ended before its session was bound.
|
|
2473
3163
|
for (const row of rows) {
|
|
2474
3164
|
const s = json(row.metadata, {})?.supercode;
|
|
2475
3165
|
try {
|
|
2476
|
-
|
|
2477
|
-
const
|
|
2478
|
-
|
|
2479
|
-
const sid = s.session_id ?? found?.address.split(':')[3];
|
|
2480
|
-
if (found && sid) { bindLaunch(board, row, s, found.pane ?? s.pane, sid); continue; }
|
|
2481
|
-
// An app-server-owned Codex thread can confirm its first input before process discovery knows that thread.
|
|
2482
|
-
// Bind only this board's live issuance, its exact pane and the native user-message receipt, never a title match.
|
|
2483
|
-
if (!row.ended_at && s.launch_key && launchReading(fleet, s) === 'present') {
|
|
2484
|
-
const receipt = await launchReceipt(board, s, 5000);
|
|
2485
|
-
if (receipt?.by === boardAddress(board.label) && receipt.pane === s.pane && receipt.live === true
|
|
2486
|
-
&& receipt.inputDelivered === true && receipt.inputReceipt === 'native-user-message'
|
|
2487
|
-
&& typeof receipt.native_session === 'string' && receipt.native_session) {
|
|
2488
|
-
bindLaunch(board, row, s, s.pane, receipt.native_session); continue;
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
3166
|
+
await readNativeHome(s);
|
|
3167
|
+
const receipt = await launchReceipt(board, s, 5000);
|
|
3168
|
+
if (bindLaunch(board, row, s, receipt)) continue;
|
|
2491
3169
|
// An ended run's launch that bound no session within its window awaits nothing: the run is over, and its pane's
|
|
2492
3170
|
// close (closeRequested) needs no session. It was reported as awaiting its session every round, for good, once
|
|
2493
3171
|
// its machine refused that close (t_7ec85b6b run 1203, 2026-10-09).
|
|
@@ -2504,7 +3182,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2504
3182
|
// another round may have bound this launch since the reading: only a launch still pending fails here
|
|
2505
3183
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(row.id)?.metadata, {})?.supercode;
|
|
2506
3184
|
if (!current?.launch_pending || current.launch_key !== s.launch_key || current.address) return null;
|
|
2507
|
-
//
|
|
3185
|
+
// An unbound fresh launch is unusable; its next run requires a deliberate replacement disposition.
|
|
2508
3186
|
setRunSession(db, row.id, { launch_pending: false, ...(s.mode === 'fresh' ? { forgotten: true } : {}) });
|
|
2509
3187
|
const payload = { error: `launch ${s.launch_key} ended before session binding` };
|
|
2510
3188
|
if (monitorsRuns(entry)) { surfaceIncident(db, board, entry, row.task_id, 'spawn_failed', payload); return { effects: [] }; }
|
|
@@ -2512,7 +3190,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2512
3190
|
return fire(db, board, entry, row.task_id, 'spawn_failed', { roles: ['platform'], payload });
|
|
2513
3191
|
});
|
|
2514
3192
|
if (moved) effects.push(...moved.effects);
|
|
2515
|
-
} else report.queued.push([row.task_id, `launch ${s.launch_key}${s.pane ? ` (pane ${s.pane})` : ''} awaits its
|
|
3193
|
+
} else report.queued.push([row.task_id, `launch ${s.launch_key}${s.pane ? ` (pane ${s.pane})` : ''} awaits its native session receipt`]);
|
|
2516
3194
|
} catch (error) { report.failed.push([row.task_id, `launch reconciliation retained: ${error.message}`]); }
|
|
2517
3195
|
}
|
|
2518
3196
|
}
|
|
@@ -2555,9 +3233,8 @@ function credentialsWanted(board, row, session, fleet) {
|
|
|
2555
3233
|
if (!session?.agent_id || !session.machine || normalMachine(session.machine) === localMachine()) return false;
|
|
2556
3234
|
if (!row.ended_at) return true;
|
|
2557
3235
|
if (!view(board, (db) => detachedSession(db, session) && personCustody(db, addressOf(session)))) return false;
|
|
2558
|
-
const address = addressOf(session);
|
|
2559
3236
|
if (!fleet?.ok || fleet.unknown?.has(normalMachine(session.machine))) return true;
|
|
2560
|
-
return
|
|
3237
|
+
return sessionAlive(fleet, session) !== false;
|
|
2561
3238
|
}
|
|
2562
3239
|
const UNREACHABLE = new Set(['machine_offline', 'REQUEST_TIMEOUT', 'unavailable', 504, '504', 'ETIMEDOUT', 'ECONNRESET']);
|
|
2563
3240
|
/** One keep (records) or removal (none) in the home of `scope` on `machine`: `{ home, leaseUntil }`, or `{ why }`. A
|
|
@@ -2657,21 +3334,15 @@ export async function drainAgentCredentials(root, slug) {
|
|
|
2657
3334
|
} finally { board.close(); }
|
|
2658
3335
|
}
|
|
2659
3336
|
|
|
2660
|
-
async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, orchestration } = {}) {
|
|
3337
|
+
async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, workspaceOptions, orchestration } = {}) {
|
|
2661
3338
|
const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
|
|
2662
3339
|
// Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
|
|
2663
3340
|
// no machine default decides it.
|
|
2664
3341
|
const window = workflowFor(board.root).params.session_window;
|
|
2665
3342
|
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');
|
|
2666
|
-
|
|
2667
|
-
const made = await resolveLocalWorkspace(board, task);
|
|
2668
|
-
runTx(board, task.id, runId, (db) => {
|
|
2669
|
-
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);
|
|
2670
|
-
});
|
|
2671
|
-
task = { ...task, workspace_path: made };
|
|
2672
|
-
}
|
|
3343
|
+
const workspace = await prepareRunWorkspace(board, task, runId, meta, workspaceOptions);
|
|
2673
3344
|
const model = task.model_override ?? lane.model;
|
|
2674
|
-
if(meta.agent_profile_intent)meta.agent_profile_intent.worker={...meta.agent_profile_intent.worker,harness:lane.harness,model:model??null,cwd:
|
|
3345
|
+
if(meta.agent_profile_intent)meta.agent_profile_intent.worker={...meta.agent_profile_intent.worker,harness:lane.harness,model:model??null,cwd:workspace};
|
|
2675
3346
|
const tail = model ? ['--model', model] : [];
|
|
2676
3347
|
if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
|
|
2677
3348
|
const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
|
|
@@ -2685,7 +3356,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2685
3356
|
else runTx(board, task.id, runId, (db) => event(db, task.id, 'agent_credentials_unkept', { machine, reason: kept.why }, runId));
|
|
2686
3357
|
}
|
|
2687
3358
|
// A card on this machine opens through this machine's own daemon; only another machine is reached with `--on`.
|
|
2688
|
-
const base = ['open', '--new', how.program, '--detach', `--window=${window}`, ...(machine === localMachine() ? [] : ['--on', task.machine]), '--cwd',
|
|
3359
|
+
const base = ['open', '--new', how.program, '--detach', `--window=${window}`, ...(machine === localMachine() ? [] : ['--on', task.machine]), '--cwd', workspace, '--env', 'SUPERCODE_BOARD_PROCESS_GUARD=1',
|
|
2689
3360
|
...Object.entries(meta.agent_env??{}).flatMap(([key,value])=>['--env',`${key}=${value}`])];
|
|
2690
3361
|
// the slot's last session: kept when live, resumed when lost, never another slot's (a reviewer's is its own)
|
|
2691
3362
|
// a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
|
|
@@ -2695,16 +3366,39 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2695
3366
|
// The slot's own last session whose close was refused for good is not waited on: it runs on, and is continued below
|
|
2696
3367
|
// (continuationOf), never started beside.
|
|
2697
3368
|
const last = view(board, (db) => lastSession(db, task.id, slot));
|
|
3369
|
+
await readNativeHome(last);
|
|
3370
|
+
// The slot's own last session, whose close a transition asked for as its run ended (a handoff the slot's next run
|
|
3371
|
+
// resumes, ADR 0019) and which is still open only because it is in a turn, is working as the slot's role: this run takes
|
|
3372
|
+
// it over rather than waiting for the turn to end and failing its launch window on that wait. The close is withdrawn
|
|
3373
|
+
// and the ended run retains the session, so it is kept below (continuationOf) and the binding transfers it
|
|
3374
|
+
// (retireSupersededRuns). An explicit start still settles the former slot first, and another agent's session or a
|
|
3375
|
+
// forgotten or incident-held one is never taken over.
|
|
3376
|
+
if (last?.session_id && last.pane && last.machine === machine && (last.harness ?? 'claude-code') === harness && last.close_requested
|
|
3377
|
+
&& !last.closed_at && !last.close_confirmed_at && !last.close_transferred_to && !last.forgotten && !last.monitor_held
|
|
3378
|
+
&& /^close deferred: its session \S+ is in a turn \(/.test(last.close_error ?? '') && !(meta.agent_id && otherAgent(board, last, meta))
|
|
3379
|
+
&& !view(board, (db) => currentRun(db, task.id)?.metadata?.supercode?.restart_authorized)) {
|
|
3380
|
+
tx(board, (db) => {
|
|
3381
|
+
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 });
|
|
3382
|
+
for (const r of db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
|
|
3383
|
+
const s = json(r.metadata, {})?.supercode;
|
|
3384
|
+
if (!r.ended_at || !sameNativeSession(s, last) || s.pane !== last.pane || s.machine !== machine || (s.slot ?? 'worker') !== slot
|
|
3385
|
+
|| !s.close_requested || s.closed_at || s.close_confirmed_at || s.close_transferred_to || claimHolds(s.close_claim)) continue;
|
|
3386
|
+
setRunSession(db, r.id, { close_requested: null, close_error: null, close_claim: null, close_unread: null, retain_after_run: true });
|
|
3387
|
+
event(db, task.id, 'session_close_withdrawn', { successor: runId, session_id: s.session_id, pane: s.pane, reason: s.close_error }, r.id);
|
|
3388
|
+
}
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
2698
3391
|
const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
|
|
2699
3392
|
.map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
|
|
2700
3393
|
.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
|
|
2701
3394
|
// Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
|
|
2702
3395
|
// checks below; an explicit close or another unresolved identity still holds the slot.
|
|
2703
3396
|
&& !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
|
|
2704
|
-
&& r.session
|
|
2705
|
-
&& !(closeRefused(r.session) && last?.session_id && r.session
|
|
3397
|
+
&& sameNativeSession(r.session, last) && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
|
|
3398
|
+
&& !(closeRefused(r.session) && last?.session_id && sameNativeSession(r.session, last)));
|
|
2706
3399
|
if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
|
|
2707
3400
|
let prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
3401
|
+
await readNativeHome(prev);
|
|
2708
3402
|
if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
|
|
2709
3403
|
// the round's own fleet reading: a launch never reads the fleet again
|
|
2710
3404
|
const fresh = fleet;
|
|
@@ -2712,9 +3406,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2712
3406
|
// a machine whose record is not current lists no session (continuationPane reads fleet.sessions): no continuation
|
|
2713
3407
|
// can be ruled out there, so nothing is resumed beside one
|
|
2714
3408
|
if (fresh.unknown?.has(normalMachine(machine))) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: machine ${machine}'s fleet record is not current: ${fresh.errors?.get(normalMachine(machine)) ?? 'no current record'}`);
|
|
2715
|
-
const
|
|
2716
|
-
|
|
2717
|
-
if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
3409
|
+
const { list: listed, error } = await paneRows(machine);
|
|
3410
|
+
if (!listed) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${error}`);
|
|
2718
3411
|
const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
|
|
2719
3412
|
if (pane && pane !== prev.pane) prev = { ...prev, pane, close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null };
|
|
2720
3413
|
fleet = fresh;
|
|
@@ -2723,15 +3416,15 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2723
3416
|
const report = { undelivered: [] };
|
|
2724
3417
|
await closeRequested(board, report, { taskId: task.id });
|
|
2725
3418
|
prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
3419
|
+
await readNativeHome(prev);
|
|
2726
3420
|
// a pane that could not be closed still holds the session: no second process is started on it
|
|
2727
3421
|
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'}`);
|
|
2728
3422
|
}
|
|
2729
3423
|
// The machine was checked before the card was claimed (launcherCheck: its board process guard and keyed launch door,
|
|
2730
|
-
// from the round's fleet reading)
|
|
2731
|
-
//
|
|
2732
|
-
// The pane id is chosen from the launch key and recorded before open runs, so a launch whose answer is lost (a
|
|
2733
|
-
// timeout, a closed stream) is still found by its pane in a later round's fleet reading, and by its session id.
|
|
3424
|
+
// from the round's fleet reading). The pane id is recorded before open runs; the keyed native receipt binds
|
|
3425
|
+
// every harness's session, including when open's reply is lost.
|
|
2734
3426
|
const openOwned = async (key, sid, mode, args) => {
|
|
3427
|
+
assertRunCurrent(board, task.id, runId);
|
|
2735
3428
|
// Keeping a live pane never prepares or mutates a different route/home.
|
|
2736
3429
|
if (harness === 'codex') {
|
|
2737
3430
|
const profile = meta.agent_profile_intent;
|
|
@@ -2740,43 +3433,69 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2740
3433
|
refreshVault(orchestration, vault);
|
|
2741
3434
|
const selected = { ...profile, name: lane.name, dir: lane.dir };
|
|
2742
3435
|
const worker = { ...profile.worker, harness: 'codex', model: model ?? undefined };
|
|
2743
|
-
let resume = null;
|
|
2744
|
-
if (mode === 'resume') {
|
|
2745
|
-
const rows = await readNative(machine, 'codex', sid, prev?.codex_home ?? null);
|
|
2746
|
-
const paths = [...new Set(rows.filter(row => row.locator?.harness === 'codex' && row.locator?.session_id === sid
|
|
2747
|
-
&& row.locator.storage?.kind === 'file').map(row => row.locator.storage.path))];
|
|
2748
|
-
if (paths.length !== 1) throw unavailable('the resumed Codex has no unique physical transcript source; no pane was launched');
|
|
2749
|
-
resume = { source: paths[0], id: sid };
|
|
2750
|
-
}
|
|
2751
3436
|
let route;
|
|
2752
|
-
if (
|
|
3437
|
+
if (mode === 'resume') {
|
|
3438
|
+
route = codexPaneRouteArguments(selected, worker, vault);
|
|
3439
|
+
route.env = { ...route.env, ...nativeHome(prev).env };
|
|
3440
|
+
} else if (machine === localMachine()) {
|
|
2753
3441
|
route = codexPaneRoute(selected, worker, vault);
|
|
2754
|
-
if (resume) projectCodexPaneResume(route.env.CODEX_HOME, resume.source, resume.id);
|
|
2755
3442
|
} else {
|
|
2756
3443
|
const prepared = await doorOn(machine, 'harness.v1.codex.pane.prepare', {
|
|
2757
3444
|
scope: `${localMachine()}:${board.root}:${lane.name}`, machine: localMachine(),
|
|
2758
|
-
cwd:
|
|
2759
|
-
...codexPaneInputs(selected, worker, vault),
|
|
3445
|
+
cwd: workspace,
|
|
3446
|
+
...codexPaneInputs(selected, worker, vault),
|
|
2760
3447
|
}, { timeout: 30_000, as: boardAddress(board.label) });
|
|
2761
3448
|
route = prepared.code === 0 ? json(prepared.stdout.trim(), null) : null;
|
|
2762
3449
|
if (!route?.env?.CODEX_HOME || !Array.isArray(route.args))
|
|
2763
3450
|
throw unavailable(`the destination did not prepare the login-free Codex route: ${prepared.stderr.trim() || 'invalid route receipt'}`);
|
|
2764
3451
|
}
|
|
2765
3452
|
meta.codex_home = route.env.CODEX_HOME;
|
|
3453
|
+
meta.native_home = mode === 'resume' ? nativeHome(prev) : null;
|
|
2766
3454
|
const separator = args.indexOf('--');
|
|
2767
3455
|
if (separator < 0) throw unavailable('the headful Codex launch has no native argument boundary');
|
|
2768
3456
|
args = [...args.slice(0, separator), ...Object.entries(route.env).flatMap(([name, value]) => ['--env', `${name}=${value}`]),
|
|
2769
3457
|
...args.slice(separator), ...route.args];
|
|
2770
|
-
if (machine === localMachine()) declareWorkerHome({ harness: 'codex', env: route.env, cwd:
|
|
3458
|
+
if (machine === localMachine()) declareWorkerHome({ harness: 'codex', env: route.env, cwd: workspace });
|
|
3459
|
+
}
|
|
3460
|
+
if (mode === 'resume' && harness !== 'codex') {
|
|
3461
|
+
meta.native_home = nativeHome(prev);
|
|
3462
|
+
const separator = args.indexOf('--');
|
|
3463
|
+
args = [...args.slice(0, separator), ...Object.entries(meta.native_home.env).flatMap(([name, value]) => ['--env', `${name}=${value}`]), ...args.slice(separator)];
|
|
2771
3464
|
}
|
|
2772
3465
|
const chosen = `p_${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
2773
3466
|
tx(board, (db) => {
|
|
2774
3467
|
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 });
|
|
3468
|
+
if (getTask(db, task.id)?.backlog) throw superseded(task.id, runId);
|
|
3469
|
+
const currentDecision = continuationDecision(board, getTask(db, task.id), runId, slot, lane, meta.role ?? slot, meta.agent_id);
|
|
3470
|
+
if (mode === 'fresh' && currentDecision.prior) {
|
|
3471
|
+
if (!currentDecision.authorization) throw continuationRefusal(currentDecision.prior, 'no current replacement decision was retained');
|
|
3472
|
+
requireReplacementTeardown(db, task.id, slot, runId);
|
|
3473
|
+
issueReplacement(db, task.id, slot, currentDecision.authorization, { run: runId, key, session: sid, harness,
|
|
3474
|
+
cardMachine: getTask(db, task.id).machine, agent: meta.agent_id, assignment: currentDecision.assignment });
|
|
3475
|
+
}
|
|
2775
3476
|
setRunSession(db, runId, { ...meta, surface: 'pane', machine, harness, slot,
|
|
2776
3477
|
session_id: sid, mode, launch_key: key, pane: chosen, launch_pending: true, launched_at: now() });
|
|
2777
3478
|
});
|
|
2778
3479
|
const result = await sc([...args.slice(0, args.indexOf('--key')), '--pane', chosen, ...args.slice(args.indexOf('--key'))], { timeout: 180_000, as: boardAddress(board.label) });
|
|
2779
|
-
const
|
|
3480
|
+
const reply = lastJson(result.stdout), pane = reply?.pane ?? chosen;
|
|
3481
|
+
// Every launch records its native home before its session is bound, local or remote: a reply that omits it (a
|
|
3482
|
+
// remote or recovered answer) is completed from the machine's own launch receipt, which the daemon recorded with
|
|
3483
|
+
// the home before it created the session.
|
|
3484
|
+
const launchedHome = reply?.native_home
|
|
3485
|
+
?? (await launchReceipt(board, { machine, launch_key: key }, 10_000).catch(() => null))?.native_home ?? null;
|
|
3486
|
+
const changedHome = launchedHome && tx(board, db => {
|
|
3487
|
+
const saved = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode;
|
|
3488
|
+
if (nativeHome(saved) && JSON.stringify(nativeHome(saved).homes) !== JSON.stringify(launchedHome.homes)) {
|
|
3489
|
+
requestSessionClose(db, task.id, { runId });
|
|
3490
|
+
return true;
|
|
3491
|
+
}
|
|
3492
|
+
setRunSession(db, runId, { native_home: launchedHome });
|
|
3493
|
+
return false;
|
|
3494
|
+
});
|
|
3495
|
+
if (changedHome) {
|
|
3496
|
+
try { await stopSession(board, { surface: 'pane', machine, harness, pane, session_id: sid, launch_key: key }); } catch { /* retained close request retries */ }
|
|
3497
|
+
throw unavailable(`launch ${key} returned a different native home; continuation was not bound`);
|
|
3498
|
+
}
|
|
2780
3499
|
// the run ended while its pane opened: the pane it opened is closed, never left working for an ended run
|
|
2781
3500
|
if (!view(board, (db) => runCurrent(db, task.id, runId))) {
|
|
2782
3501
|
try { await stopSession(board, { surface: 'pane', machine, harness, pane, session_id: sid, launch_key: key }); } catch { /* the slot's next launch settles it */ }
|
|
@@ -2786,12 +3505,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2786
3505
|
const before = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode ?? {};
|
|
2787
3506
|
setRunSession(db, runId, { ...before, pane });
|
|
2788
3507
|
});
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
launch_pending: false, launch_error: null, launched_at: now() };
|
|
2792
|
-
}
|
|
2793
|
-
const reason = result.code === 0 ? `launch ${key} opened pane ${pane}; its ${harness} session is bound from the fleet reading` :
|
|
2794
|
-
`launch ${key} (pane ${pane}) gave no answer; it is bound from the fleet reading if it opened: ${(result.stderr || result.stdout).trim().slice(-300)}`;
|
|
3508
|
+
const reason = result.code === 0 ? `launch ${key} opened pane ${pane}; its ${harness} session awaits its native launch receipt` :
|
|
3509
|
+
`launch ${key} (pane ${pane}) gave no answer; its native launch receipt will reconcile it: ${(result.stderr || result.stdout).trim().slice(-300)}`;
|
|
2795
3510
|
tx(board, (db) => {
|
|
2796
3511
|
if (!runCurrent(db, task.id, runId)) return;
|
|
2797
3512
|
setRunSession(db, runId, { launch_pending: true, launch_error: reason });
|
|
@@ -2800,105 +3515,55 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2800
3515
|
throw Object.assign(new Error(reason), { launchPending: true });
|
|
2801
3516
|
};
|
|
2802
3517
|
let session = null;
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
if (
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
//
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
// 2026-10-17, runs 1545 and 1546 ended spawn_failed resuming codex 01a14bac, agent_21e274c9's, as agent_00246aaa).
|
|
2832
|
-
// Nothing is closed here: every ended run's pane was confirmed closed above (`pending`), so a session still open at
|
|
2833
|
-
// this point is one the board does not close (a close refused for good, which runs on; a pane a person continued by
|
|
2834
|
-
// hand, swapped in above), and is released as an adopted one is (closeRequested), never closed. The release is
|
|
2835
|
-
// written to the runs that hold it before the fresh session starts, so the slot's next launch, which reads the fresh
|
|
2836
|
-
// session as the slot's last, finds no pane of them still owed a close (`pending`); a Codex launch binds from a later
|
|
2837
|
-
// round's reading, so the release cannot wait for this launch's own record.
|
|
2838
|
-
tx(board, (db) => {
|
|
2839
|
-
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 });
|
|
2840
|
-
for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
|
|
2841
|
-
const held = json(r.metadata, {})?.supercode;
|
|
2842
|
-
if (held?.session_id !== prev.session_id || (held.slot ?? 'worker') !== slot || held.close_confirmed_at || held.close_transferred_to) continue;
|
|
2843
|
-
const at = now();
|
|
2844
|
-
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 });
|
|
2845
|
-
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);
|
|
2846
|
-
}
|
|
2847
|
-
});
|
|
2848
|
-
replaced = { session_id: prev.session_id, why: `it is agent ${predecessor}'s, and this run is agent ${meta.agent_id}'s`, letGo: true };
|
|
2849
|
-
}
|
|
2850
|
-
else if (prev && (prev.harness ?? 'claude-code') !== harness) {
|
|
2851
|
-
// the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
|
|
2852
|
-
// are its context), and the last session, if still open, is closed so two harnesses never work one card
|
|
2853
|
-
if (prev.pane && !prev.closed_at && !adoptedOnly(prev)) await stopSession(board, prev); // an adopted session is released, never closed
|
|
2854
|
-
replaced = { session_id: prev.session_id, why: `the card's lane runs ${harness}, not ${prev.harness ?? 'claude-code'}`, letGo: true };
|
|
2855
|
-
} else if (prev && prev.machine === machine) {
|
|
2856
|
-
// a session the board closed, or one whose close its own command asked for (the next tick's sweep closes that
|
|
2857
|
-
// run's pane), is not kept, whatever a fleet reading taken before the close still lists
|
|
2858
|
-
const next = continuationOf(prev, prev.pane ? await panePresent(prev.machine, prev.pane) : sessionAlive(fleet, prev));
|
|
2859
|
-
if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
|
|
3518
|
+
const decision = continuationDecision(board, getTaskNow(board, task.id), runId, slot, lane, meta.role ?? slot, meta.agent_id);
|
|
3519
|
+
if (!prev && decision.prior) prev = decision.prior.session;
|
|
3520
|
+
await readNativeHome(prev);
|
|
3521
|
+
let replaced = null;
|
|
3522
|
+
if (decision.authorization) {
|
|
3523
|
+
view(board, db => requireReplacementTeardown(db, task.id, slot, runId));
|
|
3524
|
+
replaced = { session_id: decision.prior.session.session_id ?? null, why: decision.authorization.reason,
|
|
3525
|
+
authorization: decision.authorization.id, source: decision.authorization.source };
|
|
3526
|
+
} else if (prev) {
|
|
3527
|
+
if (!prev.session_id) throw continuationRefusal(decision.prior, 'the previous launch never supplied a native conversation id');
|
|
3528
|
+
if (meta.agent_id && otherAgent(board, prev, meta)) throw continuationRefusal(decision.prior, 'the conversation belongs to another agent');
|
|
3529
|
+
// A released adopted instance is still kept while alive. An unread native result is never absence.
|
|
3530
|
+
if (prev.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
|
|
3531
|
+
const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id, nativeHomes(prev)), prev);
|
|
3532
|
+
if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
|
|
3533
|
+
if (reading.alive && !prev.released && (prev.closed_at || prev.close_requested))
|
|
3534
|
+
throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before continuation`);
|
|
3535
|
+
}
|
|
3536
|
+
await readBefore(fleet, prev);
|
|
3537
|
+
const alive = sessionAlive(fleet, prev);
|
|
3538
|
+
if (alive == null) throw unavailable(`cannot establish continuation liveness for ${addressOf(prev)}`);
|
|
3539
|
+
if (alive && prev.pane && !fleetSessionRows(fleet, prev).some(row => row.pane === prev.pane))
|
|
3540
|
+
throw unavailable(`pane ${prev.pane} has no verified binding to ${addressOf(prev)} in its native home`);
|
|
3541
|
+
const next = continuationOf(prev, alive);
|
|
3542
|
+
if (next === 'wait') throw unavailable(`pane ${prev.pane} of ${addressOf(prev)} is still live; its close must be confirmed before continuation`);
|
|
3543
|
+
// the round's reading predates a close confirmed since: the pane door, read now, decides (ADR 0003)
|
|
3544
|
+
if (next === 'closed' && await panePresent(prev.machine, prev.pane))
|
|
3545
|
+
throw continuationRefusal(decision.prior, `its pane ${prev.pane} is still live after its close was confirmed`);
|
|
2860
3546
|
if (next === 'keep') {
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
} else if (prev.resumable !== false && await nativeAbsent(prev)) {
|
|
2866
|
-
// its transcript is gone (never written, or removed): nothing to resume and no context to keep, so the slot's next
|
|
2867
|
-
// session starts fresh, and the run's session_replaced event says why
|
|
2868
|
-
replaced = { session_id: prev.session_id, why: `its transcript is gone: ${prev.machine}'s complete ${prev.harness ?? 'claude-code'} discovery lists no session of that id`, letGo: true };
|
|
2869
|
-
} else if (prev.resumable !== false) {
|
|
3547
|
+
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, retain_after_run: _rr, handed_back_from: _hf, handed_back_run: _hr, forgotten: _fg, resumable: _rs, replacement_authorization: _ra, ...kept } = prev;
|
|
3548
|
+
session = { ...kept, native_home: nativeHome(prev), mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
3549
|
+
} else {
|
|
3550
|
+
nativeHomes(prev);
|
|
2870
3551
|
const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
|
|
2871
|
-
const released = view(board,db=>
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
}
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
// handoff, resumed for the slot's next run: a card has one session per slot, and every round of that slot runs in
|
|
2878
|
-
// it with the context of the rounds before
|
|
2879
|
-
if (monitorsRuns(workflowFor(board.root)) && prev.monitor_held && !authorization && !released) throw new Error(`session ${prev.session_id} is stopped by incident ${prev.incident ?? 'unknown'}; only its manager or creator may restart it with workflow start`);
|
|
2880
|
-
// a resumed session is told what this run is for, as a kept one is: the run's own prompt (a reviewer resumed for
|
|
2881
|
-
// its next round gets that round's brief and PR heads), not only that it was resumed
|
|
2882
|
-
const told = input ? `The board resumed this session for this run of card ${task.id}. ${input}` : `The board resumed this session after it was lost; you are still on card ${task.id}: ${task.title}. Carry on.`;
|
|
2883
|
-
// a session is resumed in its own harness, whatever the lane's is now
|
|
2884
|
-
const its = HARNESSES[prev.harness ?? 'claude-code'] ?? HARNESSES['claude-code'];
|
|
3552
|
+
const released = view(board,db => latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'restart_authorization_consumed'], 'id,kind')?.kind === 'dispatch_released');
|
|
3553
|
+
if (monitorsRuns(workflowFor(board.root)) && prev.monitor_held && !authorization && !released)
|
|
3554
|
+
throw continuationRefusal(decision.prior, `incident ${prev.incident ?? 'unknown'} still needs its manager's restart decision`);
|
|
3555
|
+
const told = input ? `The board resumed this session for this run of card ${task.id}. ${input}` : `The board resumed this session after it stopped; you are still on card ${task.id}: ${task.title}. Carry on.`;
|
|
3556
|
+
const its = HARNESSES[prev.harness ?? 'claude-code'];
|
|
3557
|
+
if (!its?.resume) throw continuationRefusal(decision.prior, 'its harness publishes no native resume door');
|
|
2885
3558
|
const launchKey = `board-${task.id}-r${runId}-resume`;
|
|
2886
3559
|
session = await openOwned(launchKey, prev.session_id, 'resume', ['open', '--new', its.program, ...base.slice(3),
|
|
2887
3560
|
'--key', launchKey, '--input', told, '--', ...its.resume(prev.session_id), ...tail]);
|
|
2888
|
-
}
|
|
2889
|
-
letGo: recoversRuns(workflowFor(board.root)) && Boolean(prev.resume_failed_before_live) && Boolean(prev.close_confirmed_at) };
|
|
2890
|
-
} else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
|
|
2891
|
-
// a card keeps its context: a slot's session that cannot be kept or resumed stops the launch, and only a session the
|
|
2892
|
-
// workflow let go (`forget_session`), its manager did (a reassignment to another harness's lane), or that belongs to
|
|
2893
|
-
// another agent than the run's (otherAgent) is replaced
|
|
2894
|
-
if (replaced && !prev?.forgotten && !replaced.letGo) {
|
|
2895
|
-
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`);
|
|
2896
|
-
failed.resumeFailed = { session_id: replaced.session_id, slot, why: replaced.why };
|
|
2897
|
-
throw failed;
|
|
3561
|
+
}
|
|
2898
3562
|
}
|
|
2899
3563
|
if (!session) {
|
|
2900
|
-
const
|
|
2901
|
-
const
|
|
3564
|
+
const issued = decision.authorization?.issued;
|
|
3565
|
+
const sid = issued ? issued.session : how.namesItself ? null : randomUUID();
|
|
3566
|
+
const launchKey = issued?.key ?? `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
|
|
2902
3567
|
// A fresh session is named for its card by its readable id (`voice-calls-3f`; a reviewer's `voice-calls-reviewer-3f`),
|
|
2903
3568
|
// else its handle (`vo-856-3f`), where its harness takes a name, with the start of its id after it as the name's own
|
|
2904
3569
|
// suffix; a kept or resumed session keeps its name.
|
|
@@ -2917,13 +3582,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2917
3582
|
const kind = { adopt: 'session_adopted', resume: 'session_resumed', fresh: replaced ? 'session_replaced' : 'session_started' }[session.mode];
|
|
2918
3583
|
const recorded = tx(board, (db) => {
|
|
2919
3584
|
const row = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ?').get(runId);
|
|
2920
|
-
if (row?.ended_at || getTask(db, task.id)?.current_run_id !== runId) {
|
|
3585
|
+
if (row?.ended_at || getTask(db, task.id)?.current_run_id !== runId || getTask(db, task.id)?.backlog) {
|
|
2921
3586
|
requestSessionClose(db, task.id, { runId });
|
|
2922
3587
|
return false;
|
|
2923
3588
|
}
|
|
3589
|
+
continuationDecision(board, getTask(db, task.id), runId, slot, lane, meta.role ?? slot, meta.agent_id);
|
|
2924
3590
|
// this session recorded on the run already (by a binding that got here first) is said once
|
|
2925
3591
|
const saved = json(row?.metadata, {})?.supercode;
|
|
2926
|
-
if (saved
|
|
3592
|
+
if (sameNativeSession(saved, session) && !saved.launch_pending) return true;
|
|
2927
3593
|
setRunSession(db, runId, session);
|
|
2928
3594
|
transferRetainedSession(db, task.id, runId, session);
|
|
2929
3595
|
event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
|
|
@@ -2937,9 +3603,11 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2937
3603
|
return true;
|
|
2938
3604
|
});
|
|
2939
3605
|
if (!recorded) return { ...session, mode: 'finished_during_launch' };
|
|
3606
|
+
// A kept session takes this run's input as the run's instruction, as a launched or resumed one takes it as its first
|
|
3607
|
+
// prompt: never as a notice, which its harness reads as no instruction.
|
|
2940
3608
|
if (session.mode === 'adopt' && input) {
|
|
2941
3609
|
assertRunCurrent(board, task.id, runId);
|
|
2942
|
-
await fileMail(board, session.address, input, `board-run-${task.id}-r${runId}
|
|
3610
|
+
await fileMail(board, session.address, input, `board-run-${task.id}-r${runId}`, { as: 'instruction' });
|
|
2943
3611
|
}
|
|
2944
3612
|
return session;
|
|
2945
3613
|
}
|
|
@@ -2951,7 +3619,7 @@ function transferRetainedSession(db, id, runId, session) {
|
|
|
2951
3619
|
for (const row of db.prepare('SELECT id,metadata FROM task_runs WHERE task_id=? AND id<? AND ended_at IS NOT NULL').all(id, runId)) {
|
|
2952
3620
|
const old = json(row.metadata, {})?.supercode;
|
|
2953
3621
|
if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
|
|
2954
|
-
|| old
|
|
3622
|
+
|| !sameNativeSession(old, session) || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
|
|
2955
3623
|
setRunSession(db, row.id, { close_transferred_to: runId });
|
|
2956
3624
|
event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
|
|
2957
3625
|
}
|
|
@@ -2969,36 +3637,76 @@ function otherAgent(board, prev, meta) {
|
|
|
2969
3637
|
|
|
2970
3638
|
/**
|
|
2971
3639
|
* 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
|
|
2972
|
-
* its native process): an open session that is live is kept, and the run is delivered into it; a live pane
|
|
2973
|
-
*
|
|
2974
|
-
*
|
|
3640
|
+
* its native process): an open session that is live is kept, and the run is delivered into it; a live pane whose close
|
|
3641
|
+
* is requested and not yet confirmed waits for it ('wait'); a live pane whose close was confirmed is judged by its
|
|
3642
|
+
* machine's pane door read after that close ('closed'), since the reading it was found live in may predate it; anything
|
|
3643
|
+
* else is resumed. A released session is open: its release only freed the slot, and the session was left running. So is
|
|
3644
|
+
* one whose close the machine refused for good.
|
|
2975
3645
|
*/
|
|
2976
3646
|
export function continuationOf(prev, live) {
|
|
2977
3647
|
const open = Boolean(prev.released) || closeRefused(prev) || (!prev.closed_at && !prev.close_requested);
|
|
2978
3648
|
if (open) return live ? 'keep' : 'resume';
|
|
2979
|
-
|
|
3649
|
+
if (!live || !prev.pane) return 'resume';
|
|
3650
|
+
return prev.close_confirmed_at ? 'closed' : 'wait';
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
/** The live panes on `machine` working at or under folder `path`, read from the machine now (a closing card's worktree
|
|
3654
|
+
* is retired only when none is: engine cleanupWorkspace). `also` names the same folder as its machine resolved it (its
|
|
3655
|
+
* links followed there), so a pane whose directory reads resolved still matches. Throws when the machine could not be
|
|
3656
|
+
* read. */
|
|
3657
|
+
export async function panesUnder(machine, path, also = []) {
|
|
3658
|
+
const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
|
|
3659
|
+
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
3660
|
+
if (!Array.isArray(panes)) throw new Error(`cannot read the panes on ${normalMachine(machine ?? localMachine())}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
3661
|
+
const local = normalMachine(machine ?? localMachine()) === localMachine();
|
|
3662
|
+
const real = (at) => { if (local) { try { return realpathSync(at); } catch { /* read as written */ } } return String(at).replace(/\/+$/, ''); };
|
|
3663
|
+
const roots = [...new Set([path, ...also].filter(Boolean).map(real))];
|
|
3664
|
+
return panes.filter((p) => !p.dead && typeof p.cwd === 'string' && p.cwd && roots.some((root) => real(p.cwd) === root || real(p.cwd).startsWith(`${root}/`))).map((p) => p.pane);
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
/** Whether a card session whose record never said it closed (held after its run, monitored, or closed by hand outside
|
|
3668
|
+
* the board) is gone, as its machine reads it now: its pane not live there, and its process not running (a headless
|
|
3669
|
+
* worker's pid on this machine; a native session's own activity reading, `persisted` and not `running`). Anything not
|
|
3670
|
+
* read, or read alive, is not gone. */
|
|
3671
|
+
export async function sessionGone(session) {
|
|
3672
|
+
await readNativeHome(session);
|
|
3673
|
+
const machine = session?.machine ?? localMachine();
|
|
3674
|
+
if (session?.launch_pending) return false;
|
|
3675
|
+
if (session?.surface === 'headless' && session.pid) return normalMachine(machine) === localMachine() && !pidAlive(session.pid);
|
|
3676
|
+
if (session?.pane) {
|
|
3677
|
+
const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
|
|
3678
|
+
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
3679
|
+
if (!Array.isArray(panes) || panes.some((p) => p.pane === session.pane && !p.dead)) return false;
|
|
3680
|
+
}
|
|
3681
|
+
if (session?.session_id) {
|
|
3682
|
+
const harness = session.harness ?? 'claude-code';
|
|
3683
|
+
try {
|
|
3684
|
+
const { sessions } = await discoverNative(machine, harness, session.session_id, nativeHomes(session), { activity: true });
|
|
3685
|
+
const reading = nativeReading(sessions, { ...session, harness });
|
|
3686
|
+
return reading.alive === false;
|
|
3687
|
+
} catch { return false; }
|
|
3688
|
+
}
|
|
3689
|
+
return Boolean(session?.pane);
|
|
2980
3690
|
}
|
|
2981
3691
|
|
|
2982
3692
|
/** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
|
|
2983
3693
|
async function panePresent(machine, pane) {
|
|
2984
|
-
|
|
3694
|
+
// a liveness decision about one pane (a resume, a replacement, a launch that must have survived) asks the daemon now:
|
|
3695
|
+
// the held record (paneRows) lags the host by its cycle, and a pane the board itself closed this round still reads
|
|
3696
|
+
// live there, which refused the resume of a timed-out card twice and gave the card up
|
|
3697
|
+
const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
|
|
2985
3698
|
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
2986
3699
|
if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
2987
3700
|
return panes.some((p) => p.pane === pane && !p.dead);
|
|
2988
3701
|
}
|
|
2989
3702
|
|
|
2990
|
-
/** Whether a pane just launched is still there after the harness had a moment to start. */
|
|
2991
|
-
async function paneSurvives(machine, pane) {
|
|
2992
|
-
// a timer, never a blocking wait: the owner answers verbs while the harness starts
|
|
2993
|
-
await new Promise((resolve) => setTimeout(resolve, 6000));
|
|
2994
|
-
return await panePresent(machine, pane);
|
|
2995
|
-
}
|
|
2996
|
-
|
|
2997
3703
|
/** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
|
|
2998
|
-
async function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {} } = {}) {
|
|
2999
|
-
const workspace = await
|
|
3704
|
+
async function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {}, workspaceOptions } = {}) {
|
|
3705
|
+
const workspace = await prepareRunWorkspace(board, task, runId, meta, workspaceOptions);
|
|
3706
|
+
assertRunCurrent(board, task.id, runId);
|
|
3707
|
+
await closeRequested(board, { undelivered: [] }, { taskId: task.id });
|
|
3708
|
+
assertRunCurrent(board, task.id, runId);
|
|
3000
3709
|
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};
|
|
3001
|
-
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));
|
|
3002
3710
|
// the card's own skills and the ones its status's run names (a reviewer's, for one)
|
|
3003
3711
|
const skills = [...new Set([...task.skills, ...runSkills])];
|
|
3004
3712
|
const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
|
|
@@ -3008,40 +3716,99 @@ async function launchHeadless(board, task, runId, lane, settings, { prompt = nul
|
|
|
3008
3716
|
mkdirSync(logDir, { recursive: true });
|
|
3009
3717
|
const logFile = join(logDir, `${task.id}.log`);
|
|
3010
3718
|
rotateLog(logFile, settings.logRotateBytes, settings.logBackups);
|
|
3011
|
-
const out = openSync(logFile, 'a');
|
|
3012
3719
|
const exitFile = join(logDir, `${task.id}.r${runId}.exit`);
|
|
3720
|
+
await readNativeHome(view(board, db => lastSession(db, task.id, meta.slot)));
|
|
3013
3721
|
// the launch's secret, given to this worker alone: the run keeps its hash, and the worker's session record must name it
|
|
3014
3722
|
const launchSecret = randomUUID() + randomUUID();
|
|
3723
|
+
const session = tx(board, db => {
|
|
3724
|
+
assertRunCurrent(board, task.id, runId);
|
|
3725
|
+
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode;
|
|
3726
|
+
if (current?.launch_key || current?.pid) throw continuationRefusal({ run: runId, session: current }, 'this attempt already issued its headless worker; its retained launch must be reconciled');
|
|
3727
|
+
const decision = continuationDecision(board, getTask(db, task.id), runId, meta.slot, lane, meta.role ?? meta.slot, meta.agent_id);
|
|
3728
|
+
if (decision.authorization) requireReplacementTeardown(db, task.id, meta.slot, runId);
|
|
3729
|
+
else if (decision.prior) {
|
|
3730
|
+
const previous = decision.prior.session;
|
|
3731
|
+
nativeHomes(previous);
|
|
3732
|
+
// a close under way is waited for; only one that is not, or was refused for good, needs the manager (ADR 0003)
|
|
3733
|
+
const closing = previous.close_requested && !previous.close_confirmed_at && !closeRefused(previous);
|
|
3734
|
+
if (previous.surface === 'headless' && previous.pid && pidAlive(previous.pid))
|
|
3735
|
+
throw closing ? unavailable(`its previous headless worker ${previous.pid} is still closing; its close must finish before headless resume`)
|
|
3736
|
+
: continuationRefusal(decision.prior, 'its previous headless worker is still present');
|
|
3737
|
+
if (previous.surface !== 'headless' && !previous.close_confirmed_at && !previous.close_transferred_to)
|
|
3738
|
+
throw closing ? unavailable(`its previous execution ${addressOf(previous) ?? previous.pane ?? previous.launch_key} is still closing; its close must be confirmed before headless resume`)
|
|
3739
|
+
: continuationRefusal(decision.prior, 'its previous execution has no confirmed close before headless resume');
|
|
3740
|
+
}
|
|
3741
|
+
const launchKey = randomUUID();
|
|
3742
|
+
if (decision.authorization) issueReplacement(db, task.id, meta.slot, decision.authorization, { run: runId,
|
|
3743
|
+
key: launchKey, session: null, harness: lane.harness, cardMachine: task.machine, agent: meta.agent_id, assignment: decision.assignment });
|
|
3744
|
+
const prepared = { ...meta, surface: 'headless', harness: lane.harness,
|
|
3745
|
+
...(decision.prior && !decision.authorization ? { native_home: nativeHome(decision.prior.session) } : {}),
|
|
3746
|
+
machine: executionHost(lane, task.machine, localMachine(), normalMachine), mode: decision.prior && !decision.authorization ? 'resume' : 'fresh',
|
|
3747
|
+
session_id: decision.prior && !decision.authorization ? decision.prior.session.session_id : null,
|
|
3748
|
+
launch_key: launchKey, launch_pending: false, launched_at: now(), exit_file: exitFile,
|
|
3749
|
+
continuation_failure_file: `${exitFile}.continuation`,
|
|
3750
|
+
launch_secret: createHash('sha256').update(launchSecret).digest('hex'),
|
|
3751
|
+
replacement_authorization_id: decision.authorization?.id ?? null };
|
|
3752
|
+
setRunSession(db, runId, prepared);
|
|
3753
|
+
return prepared;
|
|
3754
|
+
});
|
|
3755
|
+
const out = openSync(logFile, 'a');
|
|
3015
3756
|
// the worker runs under a shell that records its exit code, so a later tick can tell a clean exit from a crash
|
|
3016
|
-
|
|
3757
|
+
let child;
|
|
3758
|
+
try { child = spawnChild('/bin/sh', ['-c', '"$0" "$@"; echo $? > "$SUPERCODE_BOARD_EXIT_FILE"', process.execPath, WORKER, ...args], {
|
|
3017
3759
|
cwd: workspace, detached: true, stdio: ['ignore', out, out],
|
|
3018
3760
|
env: {
|
|
3019
|
-
SUPERCODE_BOARD_EXIT_FILE: exitFile,
|
|
3020
3761
|
...(task.goal_mode ? { HERMES_KANBAN_GOAL_MODE: '1', ...(task.goal_max_turns ? { HERMES_KANBAN_GOAL_MAX_TURNS: String(task.goal_max_turns) } : {}) } : {}),
|
|
3021
3762
|
...process.env, ...meta.agent_env, SUPERCODE_ORCHESTRATOR_HOME: board.root, HERMES_HOME: meta.agent_env?.HERMES_HOME??lane.dir, HERMES_PROFILE: lane.name, HERMES_KANBAN_TASK: task.id, HERMES_KANBAN_WORKSPACE: workspace,
|
|
3763
|
+
SUPERCODE_BOARD_EXIT_FILE: exitFile, SUPERCODE_BOARD_FAILURE_FILE: session.continuation_failure_file,
|
|
3022
3764
|
HERMES_KANBAN_RUN_ID: String(runId), SUPERCODE_RUN_LAUNCH: launchSecret, HERMES_KANBAN_DB: board.path, HERMES_KANBAN_BOARD: board.slug, HERMES_SESSION_SOURCE: 'kanban',
|
|
3023
3765
|
HERMES_KANBAN_CLAIM_LOCK: claimer(), ...(prompt ? { SUPERCODE_BOARD_PROMPT: prompt } : {}), ...(task.tenant ? { HERMES_TENANT: task.tenant } : {}),
|
|
3024
3766
|
...(task.branch_name ? { HERMES_KANBAN_BRANCH: task.branch_name } : {}),
|
|
3767
|
+
SUPERCODE_BOARD_RESUME_SESSION: session.mode === 'resume' ? session.session_id : '',
|
|
3768
|
+
SUPERCODE_BOARD_NATIVE_HOME: session.mode === 'resume' ? JSON.stringify(session.native_home) : '',
|
|
3025
3769
|
},
|
|
3026
|
-
});
|
|
3770
|
+
}); } finally { closeSync(out); }
|
|
3771
|
+
child.once('error', error => tx(board, db => setRunSession(db, runId, { launch_error: `spawning headless worker: ${error.message}` })));
|
|
3027
3772
|
child.unref();
|
|
3028
|
-
|
|
3773
|
+
session.pid = child.pid;
|
|
3774
|
+
// Keep the physical process reference even when a concurrent manager act invalidated this attempt.
|
|
3775
|
+
tx(board, db => setRunSession(db, runId, { pid: child.pid }));
|
|
3029
3776
|
// recorded against this run only while it is still the card's current one; a run ended meanwhile gets no worker
|
|
3777
|
+
let refused;
|
|
3030
3778
|
const recorded = tx(board, (db) => {
|
|
3031
|
-
if (!runCurrent(db, task.id, runId)) return false;
|
|
3779
|
+
if (!runCurrent(db, task.id, runId) || getTask(db, task.id)?.backlog) return false;
|
|
3780
|
+
try { continuationDecision(board, getTask(db, task.id), runId, meta.slot, lane, meta.role ?? meta.slot, meta.agent_id); }
|
|
3781
|
+
catch (error) { refused = error; return false; }
|
|
3032
3782
|
db.prepare('UPDATE tasks SET worker_pid = ? WHERE id = ? AND current_run_id = ?').run(child.pid, task.id, runId);
|
|
3033
3783
|
db.prepare('UPDATE task_runs SET worker_pid = ? WHERE id = ?').run(child.pid, runId);
|
|
3034
|
-
setRunSession(db, runId, session);
|
|
3035
3784
|
event(db, task.id, 'spawned', { pid: child.pid }, runId);
|
|
3036
3785
|
return true;
|
|
3037
3786
|
});
|
|
3038
3787
|
if (!recorded) {
|
|
3039
|
-
try { process.kill(-child.pid, 'SIGTERM'); } catch { try { process.kill(child.pid, 'SIGTERM'); } catch { /* already gone */ } }
|
|
3788
|
+
try { stopWorkChild(child, () => process.kill(-child.pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { try { stopWorkChild(child, () => process.kill(child.pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { /* already gone */ } }
|
|
3789
|
+
if (refused) throw refused;
|
|
3040
3790
|
throw Object.assign(new Error(`run ${runId} of ${task.id} ended while its worker started; the worker was stopped`), { launchSuperseded: true });
|
|
3041
3791
|
}
|
|
3042
3792
|
return session;
|
|
3043
3793
|
}
|
|
3044
3794
|
|
|
3795
|
+
/** The worker's native identity is bound only while this exact role/configuration still owns its launch. */
|
|
3796
|
+
export function assertHeadlessBinding(board, taskId, runId, harness, sessionId) {
|
|
3797
|
+
assertRunCurrent(board, taskId, runId);
|
|
3798
|
+
const task = getTaskNow(board, taskId), entry = workflowFor(board.root);
|
|
3799
|
+
const session = currentRun(board.db, taskId)?.metadata?.supercode;
|
|
3800
|
+
const spec = runSpec(entry, statusOf(board.db, task), task);
|
|
3801
|
+
const scope = scopeOf(board.db, board, task, { params: entry.params });
|
|
3802
|
+
const profile = spec ? evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
|
|
3803
|
+
const lane = lanesOf(entry.profiles)[profile];
|
|
3804
|
+
if (!lane || lane.harness !== harness || session?.harness !== harness) throw continuationRefusal({ run: runId, session }, 'the worker harness/configuration changed before native binding');
|
|
3805
|
+
const decision = continuationDecision(board, task, runId, session.slot ?? 'worker', lane, session.role ?? spec?.role ?? 'implementer', session.agent_id);
|
|
3806
|
+
if (decision.prior && !decision.authorization && sessionId !== decision.prior.session.session_id)
|
|
3807
|
+
throw continuationRefusal(decision.prior, 'the native resume door returned a different conversation id');
|
|
3808
|
+
if (decision.authorization && decision.authorization.issued?.key !== session.launch_key)
|
|
3809
|
+
throw continuationRefusal(decision.prior, 'the native binding belongs to a different replacement issuance');
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3045
3812
|
/** Hermes's worker log rotation: past `max` bytes the log moves to `.1` (… `.n`), the oldest dropped. */
|
|
3046
3813
|
function rotateLog(file, max, backups) {
|
|
3047
3814
|
try {
|
|
@@ -3052,19 +3819,48 @@ function rotateLog(file, max, backups) {
|
|
|
3052
3819
|
} catch { /* a log that cannot rotate keeps growing */ }
|
|
3053
3820
|
}
|
|
3054
3821
|
|
|
3055
|
-
/**
|
|
3056
|
-
async function
|
|
3822
|
+
/** Select the slot's native launch directory before both admission and launch, without consulting the author's branch. */
|
|
3823
|
+
async function workspaceForRun(board, task, { role, slot, lane, runId = null }) {
|
|
3824
|
+
if (role !== 'reviewer' || task.workspace_kind !== 'worktree') return { role };
|
|
3825
|
+
const decision = continuationDecision(board, task, runId, slot, lane, role);
|
|
3826
|
+
const previous = decision.authorization ? null : decision.prior?.session;
|
|
3827
|
+
let cwd = previous?.cwd ?? previous?.agent_profile_intent?.worker?.cwd ?? null;
|
|
3828
|
+
if (previous && !cwd) {
|
|
3829
|
+
await readNativeHome(previous);
|
|
3830
|
+
cwd = nativeReading(await readNative(previous.machine, previous.harness ?? 'claude-code', previous.session_id, nativeHomes(previous)), previous).row?.cwd;
|
|
3831
|
+
if (!cwd) throw unavailable(`reviewer session ${addressOf(previous)} has no readable launch directory; its continuation waits for its native cwd`);
|
|
3832
|
+
}
|
|
3833
|
+
const reviewWorkspace = view(board, db => reviewWorkspaces(db, task.id)).find(row =>
|
|
3834
|
+
row.slot === slot && normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && row.repository === task.workspace_anchor) ?? null;
|
|
3835
|
+
return { role, cwd, reviewWorkspace };
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
/** Prepare and retain the run's directory before open/spawn. A reviewer's allocation never retargets its author. */
|
|
3839
|
+
async function prepareRunWorkspace(board, task, runId, meta, options) {
|
|
3840
|
+
const made = await resolveLocalWorkspace(board, task, options);
|
|
3841
|
+
Object.assign(meta, { cwd: made.path, review_workspace: made.reviewWorkspace ?? null });
|
|
3842
|
+
runTx(board, task.id, runId, db => {
|
|
3843
|
+
assertWorkspaceOwner(db, task, { ...made, workspaceRoot: task.workspace_kind === 'worktree' ? made.path : made.root });
|
|
3844
|
+
setRunSession(db, runId, { cwd: meta.cwd, review_workspace: meta.review_workspace });
|
|
3845
|
+
if (!(task.workspace_kind === 'worktree' && meta.role === 'reviewer'))
|
|
3846
|
+
db.prepare('UPDATE tasks SET workspace_path=?,branch_name=COALESCE(branch_name,?) WHERE id=?').run(made.path, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id);
|
|
3847
|
+
});
|
|
3848
|
+
return made.path;
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
/** A run's directory on its execution machine, made through that machine's workspace door. */
|
|
3852
|
+
async function resolveLocalWorkspace(board, task, options) {
|
|
3057
3853
|
const root = board.root;
|
|
3058
3854
|
if (task.workspace_kind !== 'scratch') {
|
|
3059
3855
|
// A branch the board named for the card is claimed until its worktree is made, and the making is recorded once.
|
|
3060
3856
|
const facts = view(board, () => workspaceTask(board, task, { claims: true })), claim = facts.branch_claim;
|
|
3061
|
-
const made = await resolveWorkspace(facts, true);
|
|
3062
|
-
if (claim) tx(board, (db) => { if (branchClaim(db, task.id, task.branch_name)) event(db, task.id, 'worktree_made', { branch: task.branch_name, repository: task.workspace_anchor ?? null, path: made.path }); });
|
|
3063
|
-
return made
|
|
3857
|
+
const made = await resolveWorkspace(facts, true, options);
|
|
3858
|
+
if (claim && options.role !== 'reviewer') tx(board, (db) => { if (branchClaim(db, task.id, task.branch_name)) event(db, task.id, 'worktree_made', { branch: task.branch_name, repository: task.workspace_anchor ?? null, path: made.path }); });
|
|
3859
|
+
return made;
|
|
3064
3860
|
}
|
|
3065
3861
|
const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
|
|
3066
3862
|
mkdirSync(dir, { recursive: true });
|
|
3067
|
-
return dir;
|
|
3863
|
+
return { path: dir, root: dir };
|
|
3068
3864
|
}
|
|
3069
3865
|
|
|
3070
3866
|
// ------------------------------------------------------------------ attach
|
|
@@ -3080,26 +3876,34 @@ async function resolveLocalWorkspace(board, task) {
|
|
|
3080
3876
|
* launched), and the dispatcher's next tick verifies the session from the fleet reading it already takes and binds
|
|
3081
3877
|
* it (`completeAdoptions`). The verb does no process I/O, so it answers as fast as any board write.
|
|
3082
3878
|
*/
|
|
3083
|
-
export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3879
|
+
export async function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
|
|
3880
|
+
try {
|
|
3881
|
+
if (!who) throw requestDetail('name the session to adopt');
|
|
3882
|
+
if (boardCopyOf(board)) throw requestDetail(copyRefusal(board, 'adopts no session').message);
|
|
3883
|
+
tx(board, db => {
|
|
3884
|
+
const task = getTask(db, id);
|
|
3885
|
+
if (!task) throw requestDetail(`no card ${id}`);
|
|
3886
|
+
if (task.backlog) throw requestDetail(`${id} is in backlog; plan it before adopting a session`);
|
|
3887
|
+
// A session named by its address or id is checked now; one named otherwise is checked when it is bound.
|
|
3888
|
+
const named = /^sc:[^:]+:[^:]+:(.+)$/.exec(String(who))?.[1] ?? (/^[0-9a-f-]{8,}$/i.test(String(who)) ? String(who) : null);
|
|
3889
|
+
const why = coderAsReviewer(db, board, id, { sessionId: named, assignee, role });
|
|
3890
|
+
if (why) throw ownerRule(coderAsReviewerRefusal(id, statusOf(db, task), why));
|
|
3891
|
+
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
|
|
3892
|
+
});
|
|
3893
|
+
return { requested: who, held: true };
|
|
3894
|
+
} catch (error) {
|
|
3895
|
+
if (!error.ownerRule && !error.boardDetail) throw error;
|
|
3896
|
+
return (error.ownerRule ? routeRequest : acceptRequest)(board, { id, name: 'adopt', payload: { session: who ?? '', workspace, assignee, role }, reason: error });
|
|
3897
|
+
}
|
|
3094
3898
|
}
|
|
3095
3899
|
|
|
3096
3900
|
/**
|
|
3097
3901
|
* Why adopting a session onto card `id` would make the card's own coder its reviewer, or null. A session adopted onto a
|
|
3098
3902
|
* card whose run is its reviewer's (a card in review) is bound as that reviewer, and a reviewer is never the card's own
|
|
3099
|
-
* coder (ADR 0001 rule 8; ADR 0019
|
|
3903
|
+
* coder (ADR 0001 rule 8; ADR 0019). Its coder is a worker-slot session of
|
|
3100
3904
|
* any of its runs or one of its side sessions (`sessionId`), a profile any of its worker runs ran as or its implementer
|
|
3101
|
-
* (`assignee`), or a request for another role (`role`).
|
|
3102
|
-
* card
|
|
3905
|
+
* (`assignee`), or a request for another role (`role`). An adoption that needs this decision is handed to the manager;
|
|
3906
|
+
* it never returns the card from review implicitly. The manager's `return-review` records that decision and reason.
|
|
3103
3907
|
*/
|
|
3104
3908
|
function coderAsReviewer(db, board, id, { sessionId = null, assignee = null, role = null, assigneeBefore = undefined } = {}) {
|
|
3105
3909
|
const card = getTask(db, id);
|
|
@@ -3108,7 +3912,7 @@ function coderAsReviewer(db, board, id, { sessionId = null, assignee = null, rol
|
|
|
3108
3912
|
const held = assigneeBefore === undefined ? card.assignee : assigneeBefore;
|
|
3109
3913
|
const entry = workflowFor(board.root);
|
|
3110
3914
|
const bound = currentRun(db, id)?.metadata?.supercode;
|
|
3111
|
-
const slot = bound?.slot ?? String(templateValue(runSpec(entry, statusOf(db, card), card)?.session ?? 'worker', scopeOf(db, board, card, { params: entry.params })));
|
|
3915
|
+
const slot = bound?.slot ?? String(templateValue(runSpec(entry, statusOf(db, card), card)?.session ?? 'worker', scopeOf(db, board, card, { params: entry.params, lazy: true })));
|
|
3112
3916
|
if (slot !== 'reviewer') return null;
|
|
3113
3917
|
if (role && role !== 'reviewer') return `the adoption asks for its ${role} role`;
|
|
3114
3918
|
if (sessionId && ownCoder(db, id, sessionId)) return `${sessionId} is its own coder (a worker or side session of ${id})`;
|
|
@@ -3132,8 +3936,8 @@ const ADOPTION_LISTING_WINDOW_S = 180;
|
|
|
3132
3936
|
* The events that answer an adoption request, or hold it: a request is open while it is the card's latest of these, and
|
|
3133
3937
|
* a round that acts on it first records `adoption_claimed` (the request, its claim lock and when the claim expires).
|
|
3134
3938
|
*/
|
|
3135
|
-
const ADOPTION_ANSWERS =
|
|
3136
|
-
const latestAnswer = (db, id) => db
|
|
3939
|
+
const ADOPTION_ANSWERS = ['dispatch_held', 'dispatch_released', 'session_adopted', 'adoption_failed', 'adoption_claimed'];
|
|
3940
|
+
const latestAnswer = (db, id) => latestEvent(db, id, ADOPTION_ANSWERS, 'id,kind,payload') ?? null;
|
|
3137
3941
|
|
|
3138
3942
|
/**
|
|
3139
3943
|
* Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. A round claims
|
|
@@ -3146,7 +3950,7 @@ export async function completeAdoptions(board, fleet, settings, report) {
|
|
|
3146
3950
|
FROM (SELECT DISTINCT task_id FROM task_events) cards CROSS JOIN task_events e ON e.task_id=cards.task_id
|
|
3147
3951
|
WHERE e.kind IN ('dispatch_held','adoption_claimed')
|
|
3148
3952
|
AND ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
|
|
3149
|
-
AND e.id = (
|
|
3953
|
+
AND e.id = ${latestEventIdSql('e.task_id', ADOPTION_ANSWERS)}`).all());
|
|
3150
3954
|
for (const row of pending) {
|
|
3151
3955
|
// a request another round has claimed is that round's while its claim holds; a claim whose round died is open again
|
|
3152
3956
|
let request = row;
|
|
@@ -3201,7 +4005,7 @@ export async function completeAdoptions(board, fleet, settings, report) {
|
|
|
3201
4005
|
*/
|
|
3202
4006
|
const ADOPTION_HOLDS = new Set(['adoption requested', 'awaiting adoption']);
|
|
3203
4007
|
export function restoreHoldBefore(db, id, request) {
|
|
3204
|
-
const before = db
|
|
4008
|
+
const before = latestEvent(db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'kind,payload', 'id < ?', [request]);
|
|
3205
4009
|
const held = before?.kind === 'dispatch_held' ? json(before.payload, {}) ?? {} : null;
|
|
3206
4010
|
if (held && !ADOPTION_HOLDS.has(held.reason)) { const { by: _by, ...kept } = held; event(db, id, 'dispatch_held', { ...kept, restored_after: request }); }
|
|
3207
4011
|
else event(db, id, 'dispatch_released', { reason: 'adoption failed', request });
|
|
@@ -3222,8 +4026,14 @@ async function nativeListing(who) {
|
|
|
3222
4026
|
if (!m || !HARNESSES[m[2]]) return null;
|
|
3223
4027
|
const [, host, harness, session_id] = m;
|
|
3224
4028
|
// a discovery its machine did not answer is unknown, not absent: it is thrown (unread), never taken as no listing
|
|
3225
|
-
try {
|
|
3226
|
-
|
|
4029
|
+
try {
|
|
4030
|
+
const rows = (await readNative(host, harness, session_id)).filter(row => row.locator?.harness === harness && row.locator.session_id === session_id);
|
|
4031
|
+
const stores = new Set(rows.map(row => JSON.stringify(locatorHome(row.locator)?.homes ?? null)));
|
|
4032
|
+
if (stores.size > 1) throw Object.assign(new Error(`${who} exists in multiple native homes; an address alone cannot select its conversation`), { ambiguous: true });
|
|
4033
|
+
const reading = nativeReading(rows, { session_id, harness });
|
|
4034
|
+
return reading.alive === true ? { address: who, machine: normalMachine(host), harness, session_id, pane: null, name: null, locator: reading.row.locator } : null;
|
|
4035
|
+
}
|
|
4036
|
+
catch (error) { if (error.launchDeferred || error.ambiguous) throw error; return null; }
|
|
3227
4037
|
}
|
|
3228
4038
|
|
|
3229
4039
|
/** The verb an adoption with no current run fires: the workflow's own `adopt` where it declares one, else its dispatch start. */
|
|
@@ -3236,7 +4046,9 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
3236
4046
|
// A session no current machine lists may be one of a machine whose record is not current: the one its address names,
|
|
3237
4047
|
// or any, for a bare name. Its native listing is not taken there (it knows no pane, and the session may run in one).
|
|
3238
4048
|
const named = /^sc:([^:]+):/.exec(String(who))?.[1], machines = named ? [normalMachine(named)].filter((machine) => fleet.unknown?.has(machine)) : [...(fleet.unknown ?? [])];
|
|
3239
|
-
const
|
|
4049
|
+
const matches = fleet.sessions.filter((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
|
|
4050
|
+
if (matches.length > 1) throw new Error(`${who} matches ${matches.length} live sessions or panes (${matches.map((s) => s.address).join(', ')}); select one by its address`);
|
|
4051
|
+
const found = matches[0] ?? (named && machines.length ? null : await nativeListing(who));
|
|
3240
4052
|
if (!found) {
|
|
3241
4053
|
// said, and waited on as an unlisted session is, within the listing window
|
|
3242
4054
|
const unread = machines.map((machine) => `machine ${machine}'s record is not current: ${fleet.errors?.get(machine) ?? 'no current record'}`).join('; ');
|
|
@@ -3244,43 +4056,50 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
3244
4056
|
}
|
|
3245
4057
|
const [, host, harness, sessionId] = found.address.split(':');
|
|
3246
4058
|
if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
|
|
3247
|
-
const
|
|
4059
|
+
const home = locatorHome(found.locator);
|
|
4060
|
+
if (!home) throw unavailable(`native home is unknown for ${found.address}; adoption did not bind an unspecified store`);
|
|
4061
|
+
const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId, home?.homes), { session_id: sessionId, harness });
|
|
3248
4062
|
if (found.pane ? !await panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
|
|
3249
4063
|
const beforeRun = view(board, (db) => currentRun(db, id));
|
|
3250
4064
|
const beforeCustody = view(board, db => JSON.stringify(detachedCard(db, id)));
|
|
3251
4065
|
const beforeSession = beforeRun?.metadata?.supercode;
|
|
3252
4066
|
const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
|
|
3253
4067
|
const adoptionSlot = beforeSession?.slot ?? view(board, db => {
|
|
3254
|
-
const scope = scopeOf(db, board, card0, { params: entry0.params });
|
|
4068
|
+
const scope = scopeOf(db, board, card0, { params: entry0.params, lazy: true });
|
|
3255
4069
|
return String(templateValue(runSpec(entry0, statusOf(db, card0), card0)?.session ?? 'worker', scope));
|
|
3256
4070
|
});
|
|
3257
4071
|
const predecessor = view(board, db => lastSession(db, id, adoptionSlot));
|
|
3258
|
-
|
|
4072
|
+
await readNativeHome(beforeSession);
|
|
4073
|
+
await readNativeHome(predecessor);
|
|
4074
|
+
const incoming = { ...found, native_home: home };
|
|
4075
|
+
const sameIdentity = session => sameNativeSession(session, incoming);
|
|
3259
4076
|
// A deferred run has issued no different identity. Bind its own slot's live predecessor, or the session
|
|
3260
4077
|
// discovered in its keyed pane, without ending the run or opening another process.
|
|
3261
4078
|
const unissued = !beforeSession?.session_id && !beforeSession?.pane && !beforeSession?.launch_key && !beforeSession?.pid && !beforeSession?.launch_pending;
|
|
3262
4079
|
const keyedPane = !beforeSession?.session_id && found.pane && beforeSession?.pane === found.pane
|
|
3263
|
-
&& beforeSession?.machine === host && beforeSession?.harness === harness
|
|
4080
|
+
&& beforeSession?.machine === host && beforeSession?.harness === harness
|
|
4081
|
+
&& homeIdentity(beforeSession) === homeIdentity(incoming);
|
|
3264
4082
|
if (beforeRun && !sameIdentity(beforeSession) && !keyedPane && !(unissued && sameIdentity(predecessor))) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
|
|
3265
4083
|
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}`);
|
|
3266
4084
|
|
|
3267
4085
|
// the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
|
|
3268
4086
|
let dir = workspace ?? native?.row?.cwd;
|
|
3269
4087
|
if (!dir) {
|
|
3270
|
-
dir = (await readNative(host, harness, sessionId)).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
|
|
4088
|
+
dir = (await readNative(host, harness, sessionId, home?.homes, { activity: false })).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
|
|
3271
4089
|
}
|
|
3272
4090
|
if (!dir && found.pane) {
|
|
3273
|
-
|
|
3274
|
-
dir = (json(r.stdout.trim(), []) ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
|
|
4091
|
+
dir = ((await paneRows(host)).list ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
|
|
3275
4092
|
}
|
|
3276
4093
|
// Agents are started, not attached (owner, VO-3751). Adoption records card work only: the session
|
|
3277
4094
|
// keeps its original identity, profile, hooks, MCP and folder, including when another card's agent owns it.
|
|
3278
4095
|
// Claim the request before reading its slot again, so another round cannot bind the same request meanwhile.
|
|
3279
4096
|
const stale = (db) => {
|
|
4097
|
+
if (getTask(db, id)?.backlog) return new Error(`${id} moved to backlog during adoption; plan it before binding a session`);
|
|
3280
4098
|
if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
|
|
3281
4099
|
if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
|
|
3282
4100
|
const binding = currentRun(db, id)?.metadata?.supercode;
|
|
3283
|
-
if (['session_id', 'pane', 'launch_key', 'launch_pending', 'launch_deferred'].some(key => (binding?.[key] ?? null) !== (beforeSession?.[key] ?? null))
|
|
4101
|
+
if (['session_id', 'pane', 'launch_key', 'launch_pending', 'launch_deferred'].some(key => (binding?.[key] ?? null) !== (beforeSession?.[key] ?? null))
|
|
4102
|
+
|| homeIdentity(binding) !== homeIdentity(beforeSession)) return new Error(`${id}'s session binding changed during adoption; retry against its current session`);
|
|
3284
4103
|
if (JSON.stringify(detachedCard(db, id)) !== beforeCustody) return new Error(`${id} changed session custody during adoption; request adoption again`);
|
|
3285
4104
|
return null;
|
|
3286
4105
|
};
|
|
@@ -3305,13 +4124,11 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
3305
4124
|
const was = lastSession(db, id, adoptionSlot);
|
|
3306
4125
|
// a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
|
|
3307
4126
|
// left running, and is read)
|
|
3308
|
-
if (was && !sameIdentity(was) && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)
|
|
4127
|
+
if (was && !sameIdentity(was) && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was) !== false)
|
|
4128
|
+
throw new Error(`${id} already has a live or unread session, ${was.address}`);
|
|
3309
4129
|
if (!task.machine) db.prepare('INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine').run(id, host);
|
|
3310
4130
|
// the lane that resumes it if it is lost, set in the same transaction (assigning first would let a tick start another)
|
|
3311
4131
|
if (assignee && assignee !== task.assignee) { db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, id); event(db, id, 'assigned', { assignee }); }
|
|
3312
|
-
if (dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
|
|
3313
|
-
db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
|
|
3314
|
-
}
|
|
3315
4132
|
const entry = workflowFor(board.root);
|
|
3316
4133
|
takeSessionCustody(db, id, found.address);
|
|
3317
4134
|
event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
|
|
@@ -3332,9 +4149,13 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
3332
4149
|
const why = coderAsReviewer(db, board, id, { sessionId, assignee, role, assigneeBefore: task.assignee ?? null });
|
|
3333
4150
|
if (why) throw new Error(coderAsReviewerRefusal(id, statusOf(db, task), why));
|
|
3334
4151
|
}
|
|
3335
|
-
|
|
4152
|
+
if (runRole !== 'reviewer' && dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
|
|
4153
|
+
db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
|
|
4154
|
+
}
|
|
4155
|
+
const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now(), native_home: home, cwd: dir?.replace(/^dir:/, '') ?? null };
|
|
3336
4156
|
// a deferred launch was never issued: the adopted session is the run's, and no later round launches another
|
|
3337
4157
|
const owned = [beforeSession, was].find(s => s && s.pane === session.pane && (sameIdentity(s) || s === beforeSession && keyedPane));
|
|
4158
|
+
session.native_home ??= nativeHome(owned);
|
|
3338
4159
|
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session,
|
|
3339
4160
|
// A deferred or rebound run may retain launch intent. It must not make the delivery pass bind this
|
|
3340
4161
|
// attached session to that intent's agent; the session's existing ownership stays with its starter.
|
|
@@ -3366,21 +4187,22 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
3366
4187
|
const saved = json(row.metadata,{})?.supercode;
|
|
3367
4188
|
if (!saved?.session_id || saved.forgotten || saved.close_transferred_to || view(board, db => detachedCard(db, row.task_id) || detachedSession(db, saved))) continue;
|
|
3368
4189
|
if (!saved.monitor_held && !['crashed','closed','reclaimed','failed'].includes(row.outcome)) continue;
|
|
3369
|
-
|
|
4190
|
+
await readNativeHome(saved);
|
|
4191
|
+
const candidates = fleetSessionRows(fleet, saved).filter(s => s.state === 'running' && s.pane);
|
|
4192
|
+
const live = new Set(candidates.map(s => s.pane)).size === 1 ? candidates[0] : null;
|
|
3370
4193
|
if (!live) continue;
|
|
3371
4194
|
if (!panes.has(saved.machine)) {
|
|
3372
|
-
const
|
|
3373
|
-
|
|
3374
|
-
panes.set(saved.machine,Array.isArray(list) ? new Set(list.filter(p=>!p.dead).map(p=>p.pane)) : null);
|
|
4195
|
+
const { list } = await paneRows(saved.machine);
|
|
4196
|
+
panes.set(saved.machine,list ? new Set(list.filter(p=>!p.dead).map(p=>p.pane)) : null);
|
|
3375
4197
|
}
|
|
3376
4198
|
const pane = live.pane;
|
|
3377
4199
|
if (!pane || !panes.get(saved.machine)?.has(pane)) continue;
|
|
3378
4200
|
// A surviving timed-out process is not a manual restart. It needs a new pane.
|
|
3379
4201
|
if (pane === saved.pane && !saved.closed_at && !['crashed','closed','reclaimed'].includes(row.outcome)) continue;
|
|
3380
4202
|
tx(board, db => {
|
|
3381
|
-
const task = getTask(db,row.task_id), scope = scopeOf(db,board,task,{params:entry.params});
|
|
4203
|
+
const task = getTask(db,row.task_id), scope = scopeOf(db,board,task,{params:entry.params,lazy:true});
|
|
3382
4204
|
const automaticPause = scope.card.paused?.reason?.startsWith('its session ended cleanly (');
|
|
3383
|
-
if (task.detached || detachedSession(db, saved) || task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
|
|
4205
|
+
if (task.backlog || task.detached || detachedSession(db, saved) || task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
|
|
3384
4206
|
if (automaticPause) db.prepare('UPDATE supercode_cards SET paused=NULL WHERE task_id=?').run(row.task_id);
|
|
3385
4207
|
const session = { ...saved,pane,mode:'manual-continuation',state:entry.workflow.run?.initial ?? 'active',monitor_held:false,
|
|
3386
4208
|
prior_intervals:[...(saved.prior_intervals??[]),{ended_at:row.ended_at,outcome:row.outcome,error:row.error}],
|
|
@@ -3408,31 +4230,48 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
3408
4230
|
* writes it through the mailbox's filing door, idempotent by the message id `key` fixes. Nothing here waits on the
|
|
3409
4231
|
* event loop. The mailbox delivers it: the machine daemon's watch loop hands it to its session by the session's own
|
|
3410
4232
|
* door, and carries mail for another machine there through Teams. `wake: false` leaves it for the session's next turn.
|
|
3411
|
-
*
|
|
3412
|
-
*
|
|
4233
|
+
* `as` says what it is: a `notice` (the default) is filed as the mailbox's notice, which its receiver reads as no
|
|
4234
|
+
* instruction and answers nowhere; a `question` the board waits on, and a run's `instruction` (an adopted session's run
|
|
4235
|
+
* input or brief, the text a launched or resumed session takes as its first prompt, carried as it is), are ordinary mail
|
|
4236
|
+
* a session can reply to; the board's mailbox takes a reply and marks it read (readMailbox).
|
|
4237
|
+
* Throws only when the filing failed.
|
|
3413
4238
|
*/
|
|
3414
|
-
async function fileMail(board, to, body, key, {
|
|
4239
|
+
async function fileMail(board, to, body, key, { as = 'notice', wake = true, subject, eligible = null } = {}) {
|
|
3415
4240
|
if (boardCopyOf(board)) throw copyRefusal(board, 'files no mail for sessions or managers');
|
|
4241
|
+
// Every filing is recorded with where its time went (ADR 0066's delivery latency): its custody transactions around
|
|
4242
|
+
// the door, the door's round trip, and the daemon's own phases (its answer's `timing`).
|
|
4243
|
+
const began = performance.now();
|
|
4244
|
+
let doorMs = null, daemon = null;
|
|
3416
4245
|
const perform = async () => {
|
|
3417
4246
|
if (eligible && !eligible()) return { filed: false, new: false, skipped: true };
|
|
3418
4247
|
const request = {
|
|
3419
|
-
from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, notice:
|
|
3420
|
-
body:
|
|
4248
|
+
from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, notice: as === 'notice', subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
|
|
4249
|
+
body: as === 'instruction' ? String(body) : as === 'question' ? `[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.)`,
|
|
3421
4250
|
// the board's own Teams context (the call it files in): the sender is observed in that team, never the daemon's
|
|
3422
4251
|
context: current().facts.SUPERCODE_CONTEXT ?? null,
|
|
3423
4252
|
};
|
|
3424
|
-
const
|
|
4253
|
+
const doorBegan = performance.now();
|
|
4254
|
+
const r = await runtimeDoor(null, 'harness.v1.mail.file', { ...request, sent_at_ms: Date.now() }, { timeout: 30_000 });
|
|
4255
|
+
doorMs = performance.now() - doorBegan;
|
|
3425
4256
|
const answer = r.code === 0 ? lastJson(r.stdout) : null;
|
|
4257
|
+
daemon = answer?.timing ?? null;
|
|
3426
4258
|
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));
|
|
3427
4259
|
return { filed: true, message_id: answer.message_id, new: answer.new === true };
|
|
3428
4260
|
};
|
|
3429
4261
|
// Dispatcher mail reserves this exact identity before entering the mailbox door.
|
|
3430
4262
|
if (!board.db) throw new Error('mail filing requires the board custody store');
|
|
4263
|
+
let outcome = 'ok';
|
|
3431
4264
|
try { return await sessionOperation(board, { address: to, kind: 'mail filing' }, perform); }
|
|
3432
4265
|
catch (error) {
|
|
4266
|
+
outcome = error.sessionDetached ? 'detached' : 'failed';
|
|
3433
4267
|
if (!error.sessionDetached) throw error;
|
|
3434
4268
|
return { filed: false, new: false, detached: true };
|
|
3435
4269
|
}
|
|
4270
|
+
finally {
|
|
4271
|
+
const ms = performance.now() - began;
|
|
4272
|
+
recordSlow({ kind: 'call', name: 'board mail.file', ms, outcome, includeFast: true, extra: { as,
|
|
4273
|
+
door_ms: doorMs === null ? null : Math.round(doorMs), custody_ms: doorMs === null ? null : Math.round(ms - doorMs), daemon } });
|
|
4274
|
+
}
|
|
3436
4275
|
}
|
|
3437
4276
|
|
|
3438
4277
|
/** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
|
|
@@ -3475,8 +4314,7 @@ export async function fileBoardMail(board, report, taskId = null) {
|
|
|
3475
4314
|
// upgrade or a long stop): it is passed over, recorded once, never filed
|
|
3476
4315
|
const maxAge = Number(workflowFor(board.root)?.params?.notice_max_age_seconds) || null;
|
|
3477
4316
|
if (maxAge) tx(board, db => {
|
|
3478
|
-
for (const row of db.
|
|
3479
|
-
AND NOT EXISTS (SELECT 1 FROM task_events done WHERE done.kind IN ('board_message_sent', 'board_message_passed') AND done.task_id=original.task_id AND json_extract(done.payload,'$.event')=original.id)`).all(now() - maxAge))
|
|
4317
|
+
for (const row of pendingRequestRows(db).pending.filter(row => row.created_at < now() - maxAge))
|
|
3480
4318
|
event(db, row.task_id, 'board_message_passed', { event: row.id, reason: `older than notice_max_age_seconds (${maxAge})` });
|
|
3481
4319
|
});
|
|
3482
4320
|
const planned = tx(board, db => pendingRequestMail(db, taskId));
|
|
@@ -3488,13 +4326,13 @@ export async function fileBoardMail(board, report, taskId = null) {
|
|
|
3488
4326
|
const task = view(board, db => getTask(db, first.task_id));
|
|
3489
4327
|
const several = mail.requester && !mail.message_id && new Set(mail.members.map((m) => m.task_id)).size > 1;
|
|
3490
4328
|
const subject = numbered(boardKey(board.root, board.slug), several ? 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'}`, several ? null : noticeName(task));
|
|
3491
|
-
const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, {
|
|
4329
|
+
const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { as: mail.message_id ? 'question' : 'notice', subject });
|
|
3492
4330
|
tx(board, db => {
|
|
3493
4331
|
if (filed.detached) {
|
|
3494
4332
|
for (const member of mail.members) event(db, member.task_id, 'board_message_passed', { event: member.id, to: mail.to, reason: 'session detached to a person' });
|
|
3495
4333
|
return;
|
|
3496
4334
|
}
|
|
3497
|
-
for (const member of mail.members) if (!db.prepare("SELECT 1 FROM task_events WHERE kind
|
|
4335
|
+
for (const member of mail.members) if (!db.prepare("SELECT 1 FROM task_events WHERE kind IN ('board_message_sent','board_message_passed') AND task_id=? AND json_extract(CASE WHEN json_valid(payload) THEN payload END,'$.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 } : {}) });
|
|
3498
4336
|
});
|
|
3499
4337
|
if (filed.filed) (report.mailed ??= []).push([first.task_id, mail.to]);
|
|
3500
4338
|
} catch (error) {
|
|
@@ -3506,14 +4344,12 @@ export async function fileBoardMail(board, report, taskId = null) {
|
|
|
3506
4344
|
/** The board's own address: where its notices come from and where card updates are mailed. */
|
|
3507
4345
|
export const boardAddress = (label) => `sc:${localMachine()}:board:${boardName(label)}`;
|
|
3508
4346
|
|
|
3509
|
-
/** Consecutive failed sends after which a chat-platform subscription is dropped (Hermes's notifier drops a dead chat after 12). */
|
|
3510
|
-
const MAX_SEND_FAILURES = 12;
|
|
3511
|
-
|
|
3512
4347
|
/**
|
|
3513
4348
|
* What an audience is told of an event: the workflow's `notify[audience][kind]` template over the card, the event's
|
|
3514
4349
|
* payload and (for a comment) the comment; null when the workflow names no such notice.
|
|
3515
4350
|
*/
|
|
3516
4351
|
export function noticeOf(entry, audience, kind, task, payload, comment = null, recipient = null) {
|
|
4352
|
+
if (kind === 'session_stalled' && payload?.turn_readable === false) return null;
|
|
3517
4353
|
const template = entry.workflow.notify?.[audience]?.[kind];
|
|
3518
4354
|
if (template === undefined || template === null) return null;
|
|
3519
4355
|
const cut = (text, n) => String(text ?? '').slice(0, Number(n));
|
|
@@ -3528,36 +4364,59 @@ export function noticeOf(entry, audience, kind, task, payload, comment = null, r
|
|
|
3528
4364
|
* launch or a missed fleet read lost comes back), and each of the workflow's managers (`params.managers`) hears every
|
|
3529
4365
|
* open arc (not its subtasks) as the `manager` audience, whoever filed it, when the workflow's notify names one.
|
|
3530
4366
|
*/
|
|
3531
|
-
|
|
4367
|
+
const inheritedSubtaskFollower = `platform = 'supercode' AND thread_id = ''
|
|
4368
|
+
AND task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of IS NOT NULL)
|
|
4369
|
+
AND ((json_extract(delivery_metadata, '$.role') = 'manager' AND json_extract(delivery_metadata, '$.own') IS NULL)
|
|
4370
|
+
OR (COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') = 'creator'
|
|
4371
|
+
AND json_extract(delivery_metadata, '$.own') IS NULL
|
|
4372
|
+
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = kanban_notify_subs.task_id AND t.created_by IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
|
|
4373
|
+
AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = kanban_notify_subs.task_id AND e.kind = 'created'
|
|
4374
|
+
AND json_extract(e.payload, '$.creator') IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
|
|
4375
|
+
AND EXISTS (SELECT 1 FROM kanban_notify_subs arc JOIN supercode_cards c ON c.subtask_of = arc.task_id
|
|
4376
|
+
WHERE c.task_id = kanban_notify_subs.task_id AND arc.chat_id = kanban_notify_subs.chat_id AND arc.platform = 'supercode')))`;
|
|
4377
|
+
async function ensureSubscriptions(board, entry) {
|
|
3532
4378
|
const managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : [];
|
|
3533
4379
|
const toManagers = Object.keys(entry.workflow.notify?.manager ?? {}).length > 0;
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
4380
|
+
const cards = view(board, db => db.prepare(`SELECT DISTINCT task_id AS id FROM kanban_notify_subs
|
|
4381
|
+
WHERE platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL
|
|
4382
|
+
UNION SELECT DISTINCT task_id AS id FROM kanban_notify_subs WHERE ${inheritedSubtaskFollower}
|
|
4383
|
+
UNION SELECT t.id FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
4384
|
+
WHERE ? AND t.status NOT IN ('done','archived') AND c.subtask_of IS NULL`).all(toManagers ? 1 : 0));
|
|
4385
|
+
for (const { id } of cards) {
|
|
4386
|
+
await yieldToOwner();
|
|
4387
|
+
if (stopping) return;
|
|
4388
|
+
const needed = view(board, db => {
|
|
4389
|
+
if (db.prepare(`SELECT 1 FROM kanban_notify_subs WHERE task_id=? AND ${inheritedSubtaskFollower} LIMIT 1`).get(id)
|
|
4390
|
+
|| reconcileDependencySubscriptions(db, id, { check: true })) return true;
|
|
4391
|
+
const task = toManagers && getTask(db, id);
|
|
4392
|
+
return task && !task.subtask_of && !['done', 'archived'].includes(task.status)
|
|
4393
|
+
&& managers.some(address => subscribeIn(db, id, address, 'manager', { check: true }));
|
|
4394
|
+
});
|
|
4395
|
+
if (!needed) continue;
|
|
4396
|
+
tx(board, (db) => {
|
|
4397
|
+
reconcileDependencySubscriptions(db, id);
|
|
4398
|
+
// A subtask's watchers are its own (its creator, its worker, whoever comments on it or is mentioned there), as an
|
|
4399
|
+
// issue tracker's sub-task's are: the manager follows arcs, and an arc's follower is not copied onto its subtasks.
|
|
4400
|
+
// Such copies made before are removed here.
|
|
4401
|
+
db.prepare(`DELETE FROM kanban_notify_subs WHERE task_id=? AND ${inheritedSubtaskFollower}`).run(id);
|
|
4402
|
+
const task = getTask(db, id);
|
|
4403
|
+
if (toManagers && task && !task.subtask_of && !['done', 'archived'].includes(task.status))
|
|
4404
|
+
for (const m of managers) subscribeIn(db, id, m, 'manager');
|
|
4405
|
+
});
|
|
4406
|
+
}
|
|
4407
|
+
for (const { id, run_id } of view(board, db => db.prepare('SELECT id, current_run_id AS run_id FROM tasks WHERE current_run_id IS NOT NULL').all())) {
|
|
4408
|
+
await yieldToOwner();
|
|
4409
|
+
if (stopping) return;
|
|
4410
|
+
const binding = db => {
|
|
4411
|
+
if (!runCurrent(db, id, run_id)) return null;
|
|
4412
|
+
const session = currentRun(db, id)?.metadata?.supercode;
|
|
4413
|
+
return (session?.slot ?? 'worker') === 'worker' ? addressOf(session) : null;
|
|
4414
|
+
};
|
|
4415
|
+
const needed = view(board, db => { const address = binding(db); return address && replaceWorkerSubscriptions(db, id, address, { check: true }); });
|
|
4416
|
+
if (!needed) continue;
|
|
4417
|
+
tx(board, db => { const address = binding(db); if (address) replaceWorkerSubscriptions(db, id, address); });
|
|
4418
|
+
}
|
|
3559
4419
|
}
|
|
3560
|
-
|
|
3561
4420
|
/**
|
|
3562
4421
|
* Whether the session at `address` is known to have ended: one of card `taskId`'s runs carried it, and no run (of any
|
|
3563
4422
|
* card) carrying it is unended or keeps it open after its run (retain_after_run, not since closed or handed on). One
|
|
@@ -3623,15 +4482,21 @@ async function noticeSessions() {
|
|
|
3623
4482
|
/** Retain a subscription's wait across passes and owner restarts; only transitions enter the log report. */
|
|
3624
4483
|
function noticeWait(board, sub, report, reason = null) {
|
|
3625
4484
|
if (reason === null && !sub.delivery_metadata?.notice_wait) return;
|
|
3626
|
-
const
|
|
3627
|
-
|
|
3628
|
-
|
|
4485
|
+
const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
|
|
4486
|
+
const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
|
|
4487
|
+
// the retained wait, when this reason would change it (ADR 0247: an unchanged wait opens no transaction)
|
|
4488
|
+
const transition = db => {
|
|
3629
4489
|
const row = db.prepare(`SELECT delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3630
|
-
if (!row) return
|
|
4490
|
+
if (!row) return null;
|
|
3631
4491
|
const metadata = json(row.delivery_metadata, {}) ?? {};
|
|
3632
|
-
if ((metadata.role ?? 'creator') !== (sub.delivery_metadata?.role ?? 'creator')) return
|
|
4492
|
+
if ((metadata.role ?? 'creator') !== (sub.delivery_metadata?.role ?? 'creator')) return null;
|
|
4493
|
+
return (metadata.notice_wait?.reason ?? null) === reason ? null : metadata;
|
|
4494
|
+
};
|
|
4495
|
+
if (!view(board, transition)) return;
|
|
4496
|
+
const changed = tx(board, db => {
|
|
4497
|
+
const metadata = transition(db);
|
|
4498
|
+
if (!metadata) return false;
|
|
3633
4499
|
const previous = metadata.notice_wait;
|
|
3634
|
-
if ((previous?.reason ?? null) === reason) return false;
|
|
3635
4500
|
if (reason === null) delete metadata.notice_wait;
|
|
3636
4501
|
else metadata.notice_wait = { reason, since: now() };
|
|
3637
4502
|
db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`).run(JSON.stringify(metadata), ...identity);
|
|
@@ -3705,15 +4570,13 @@ const followerRole = (sub) => ['worker', 'dependency', 'manager', 'participant']
|
|
|
3705
4570
|
async function endEndedSubscriptions(board, fleet, report) {
|
|
3706
4571
|
const followers = "platform = 'supercode' AND json_extract(delivery_metadata, '$.dependent_card') IS NULL AND COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') != 'dependency'";
|
|
3707
4572
|
// each native subscriber's liveness is read before the write
|
|
3708
|
-
const aliveNow = new Map()
|
|
4573
|
+
const aliveNow = new Map();
|
|
3709
4574
|
if (fleet.ok) for (const { chat_id: address } of board.db.prepare(`SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE ${followers}`).all()) {
|
|
3710
4575
|
if (!sessionAt(address)) continue;
|
|
3711
4576
|
aliveNow.set(address, await subscriberAlive(fleet, address));
|
|
3712
|
-
if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
|
|
3713
4577
|
}
|
|
3714
|
-
|
|
3715
|
-
const
|
|
3716
|
-
const ended = new Map(), records = new Map();
|
|
4578
|
+
const pending = db => {
|
|
4579
|
+
const selected = [], records = new Map();
|
|
3717
4580
|
for (const row of db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE ${followers}`).all()) {
|
|
3718
4581
|
const address = row.chat_id;
|
|
3719
4582
|
if (!sessionAt(address)) continue;
|
|
@@ -3722,10 +4585,19 @@ async function endEndedSubscriptions(board, fleet, report) {
|
|
|
3722
4585
|
const record = latest.get(address);
|
|
3723
4586
|
// a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
|
|
3724
4587
|
if (record?.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
|
|
3725
|
-
//
|
|
3726
|
-
const evidence = record?.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at }
|
|
3727
|
-
|
|
3728
|
-
|
|
4588
|
+
// Only a confirmed native close ends a retained subscription.
|
|
4589
|
+
const evidence = record?.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at } : null;
|
|
4590
|
+
if (evidence) selected.push({ row, record, evidence });
|
|
4591
|
+
}
|
|
4592
|
+
return selected;
|
|
4593
|
+
};
|
|
4594
|
+
if (!view(board, db => pending(db).length)) return;
|
|
4595
|
+
tx(board, (db) => {
|
|
4596
|
+
const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
|
|
4597
|
+
const ended = new Map();
|
|
4598
|
+
for (const { row, record, evidence } of pending(db)) {
|
|
4599
|
+
const address = row.chat_id;
|
|
4600
|
+
if (!remove.run(row.task_id, address, row.thread_id).changes) continue;
|
|
3729
4601
|
if (!ended.has(address)) ended.set(address, { record, evidence, cards: [], roles: new Set() });
|
|
3730
4602
|
ended.get(address).cards.push(row.task_id);
|
|
3731
4603
|
ended.get(address).roles.add(json(row.delivery_metadata, {})?.role ?? 'creator');
|
|
@@ -3753,13 +4625,14 @@ async function foldAgentSessions(board, entry, report) {
|
|
|
3753
4625
|
for (const session of agentMains.get(agent)?.sessions ?? []) if (sessionAt(session) && !agentOf.has(session)) agentOf.set(session, agent);
|
|
3754
4626
|
}
|
|
3755
4627
|
if (!agentOf.size) return;
|
|
4628
|
+
const sessions = [...agentOf.keys()];
|
|
4629
|
+
const pending = db => db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id IN (${sessions.map(() => '?').join(', ')})`).all(...sessions)
|
|
4630
|
+
.map(row => ({ row, metadata: json(row.delivery_metadata, {}) ?? {} }))
|
|
4631
|
+
.filter(({ metadata }) => !['worker', 'dependency'].includes(metadata.role) && !metadata.dependent_card);
|
|
4632
|
+
if (!view(board, db => pending(db).length)) return;
|
|
3756
4633
|
tx(board, (db) => {
|
|
3757
|
-
const sessions = [...agentOf.keys()];
|
|
3758
|
-
const rows = db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id IN (${sessions.map(() => '?').join(', ')})`).all(...sessions);
|
|
3759
4634
|
const moved = new Map();
|
|
3760
|
-
for (const row of
|
|
3761
|
-
const metadata = json(row.delivery_metadata, {}) ?? {};
|
|
3762
|
-
if (['worker', 'dependency'].includes(metadata.role) || metadata.dependent_card) continue;
|
|
4635
|
+
for (const { row, metadata } of pending(db)) {
|
|
3763
4636
|
const agent = agentOf.get(row.chat_id);
|
|
3764
4637
|
const head = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(row.task_id).c;
|
|
3765
4638
|
const { notice_wait: _wait, failures: _failures, ...kept } = metadata;
|
|
@@ -3827,7 +4700,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3827
4700
|
const NOTICE_CEILING = Math.max(Number(entry.params.notice_ceiling) || NOTICE_CEILING_DEFAULT, 1);
|
|
3828
4701
|
const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
|
|
3829
4702
|
const identityOf = (sub) => [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
|
|
3830
|
-
const forward = (db, sub, to) => db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(to, ...identityOf(sub), to);
|
|
4703
|
+
const forward = (db, sub, to) => db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND created_at = ? AND last_event_id < ?`).run(to, ...identityOf(sub), sub.created_at, to);
|
|
3831
4704
|
const rank = (sub) => NOTICE_RANK.indexOf(followerRole(sub));
|
|
3832
4705
|
// cursors moved past what was read with nothing to tell, written together after the pass
|
|
3833
4706
|
const quiet = [];
|
|
@@ -3842,8 +4715,6 @@ async function notify(board, fleet, report, entry) {
|
|
|
3842
4715
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode').sort((a, b) => rank(a) - rank(b))) {
|
|
3843
4716
|
await yieldToOwner();
|
|
3844
4717
|
if (stopping) break;
|
|
3845
|
-
if (view(board, db => personCustody(db, sub.chat_id))) continue;
|
|
3846
|
-
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
3847
4718
|
// an unsubscribed follower is told nothing until a mention or its own comment brings it back (engine.follow)
|
|
3848
4719
|
if (sub.delivery_metadata?.unsubscribed) continue;
|
|
3849
4720
|
const role = followerRole(sub);
|
|
@@ -3854,8 +4725,9 @@ async function notify(board, fleet, report, entry) {
|
|
|
3854
4725
|
const toldAs = (kind) => roles.find((r) => heardBy(entry, r).includes(kind)) ?? role;
|
|
3855
4726
|
// the card's last event, read before its news: the cursor moves to it past every event, heard or not
|
|
3856
4727
|
const head = Number(board.db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(sub.task_id).c);
|
|
3857
|
-
if (!kinds.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head });
|
|
3858
|
-
// Read pending news before liveness: a subscription with nothing to file is not waiting on delivery
|
|
4728
|
+
if (!kinds.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); continue; }
|
|
4729
|
+
// Read pending news before liveness: a subscription with nothing to file is not waiting on delivery, and its
|
|
4730
|
+
// retained wait stays as it was until a filing clears it.
|
|
3859
4731
|
// the page starts past every event already history (older than liveSeconds), so a long backlog never holds back
|
|
3860
4732
|
// what is news now
|
|
3861
4733
|
// A follower's news is history past liveSeconds; a worker's or a dependency's (a verdict it must act on, its run
|
|
@@ -3866,7 +4738,10 @@ async function notify(board, fleet, report, entry) {
|
|
|
3866
4738
|
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND id <= ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
|
|
3867
4739
|
.all(sub.task_id, Math.max(Number(sub.last_event_id), historyThrough), head, ...kinds);
|
|
3868
4740
|
const through = events.length === 64 ? events[63].id : head;
|
|
3869
|
-
if (!events.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head });
|
|
4741
|
+
if (!events.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); continue; }
|
|
4742
|
+
// The first obstruction this pass reads is the wait it records; the subscription keeps its cursor.
|
|
4743
|
+
if (view(board, db => personCustody(db, sub.chat_id))) { noticeWait(board, sub, report, "its session is in a person's custody; its notices wait"); continue; }
|
|
4744
|
+
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) { noticeWait(board, sub, report, 'its dependency recipient is not bound; its notices wait'); continue; }
|
|
3870
4745
|
const record = view(board, db => sessionRecord(db, sub.chat_id));
|
|
3871
4746
|
// Events name the acting native session; subscriptions may name its agent.
|
|
3872
4747
|
// Use the daemon's retained binding, including former mains, for both
|
|
@@ -3904,7 +4779,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3904
4779
|
if (alive === false) { noticeWait(board, sub, report, 'its session is not running; its notices wait'); continue; }
|
|
3905
4780
|
// unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
|
|
3906
4781
|
if (alive === null) { noticeWait(board, sub, report, 'its liveness could not be read; its notices wait until it can be'); continue; }
|
|
3907
|
-
} else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
|
|
4782
|
+
} else if (!sessionAt(sub.chat_id) && ended) { noticeWait(board, sub, report, 'its session has ended; its notices wait'); continue; } // native sessions already required a current live observation
|
|
3908
4783
|
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's;
|
|
3909
4784
|
// `person` the person it is assigned to, if any)
|
|
3910
4785
|
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, person: personOf(t.assignee) } : t; });
|
|
@@ -3945,7 +4820,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3945
4820
|
}
|
|
3946
4821
|
read.push({ sub, role, task, kinds });
|
|
3947
4822
|
// nothing to tell: its cursor moves past what was read
|
|
3948
|
-
if (!items.length) { quiet.push({ sub, through });
|
|
4823
|
+
if (!items.length) { quiet.push({ sub, through }); continue; }
|
|
3949
4824
|
const digest = digests.get(recipient) ?? { recipient, to: sub.chat_id, cards: new Map() };
|
|
3950
4825
|
// a recipient that follows as its agent is mailed at its agent's address, as it subscribed
|
|
3951
4826
|
if (agentAt(sub.chat_id) && !agentAt(digest.to)) digest.to = sub.chat_id;
|
|
@@ -3964,23 +4839,22 @@ async function notify(board, fleet, report, entry) {
|
|
|
3964
4839
|
try {
|
|
3965
4840
|
const filed = await fileMail(board, digest.to, body, key, { wake, subject,
|
|
3966
4841
|
eligible: () => view(board, db => subs.some(({ sub, through }) => {
|
|
3967
|
-
const row = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}
|
|
4842
|
+
const row = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where} AND created_at = ?`).get(...identityOf(sub), sub.created_at);
|
|
3968
4843
|
return Boolean(row) && Number(row.last_event_id) < through;
|
|
3969
4844
|
})) });
|
|
3970
4845
|
tx(board, (db) => {
|
|
3971
4846
|
for (const { sub, through } of subs) forward(db, sub, through);
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
if (filed.new) for (const i of items) event(db, i.sub.task_id, 'notice_filed', { source_event: i.ev.id, kind: i.ev.kind, address: digest.to, message_id: filed.message_id, wake, ...(items.length > 1 ? { batched: items.length } : {}), ...(kind === 'summary' ? { summary: true } : {}) });
|
|
4847
|
+
state.sent.push(passAt);
|
|
4848
|
+
for (const c of cards) state.cards[c.task_id] = passAt;
|
|
4849
|
+
db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(state.key, JSON.stringify({ sent: state.sent, cards: state.cards }));
|
|
4850
|
+
if (filed.new) for (const i of items) event(db, i.sub.task_id, 'notice_filed', { source_event: i.ev.id, kind: i.ev.kind, address: digest.to, message_id: filed.message_id, wake,
|
|
4851
|
+
...(items.length > 1 ? { batched: items.length } : {}), ...(kind === 'summary' ? { summary: true } : {}) });
|
|
3978
4852
|
if (filed.new && kind === 'summary') event(db, cards[0].task_id, 'notice_flood_collapsed', { address: digest.to, recipient: digest.recipient, notices: items.length,
|
|
3979
4853
|
cards: cards.map((c) => c.task_id).slice(0, 50), card_count: cards.length, ceiling: NOTICE_CEILING, window_seconds: window, message_id: filed.message_id,
|
|
3980
4854
|
reason: `more than ${NOTICE_CEILING} notice messages in ${window} s for one recipient: the rest are one summary` });
|
|
3981
4855
|
});
|
|
3982
|
-
|
|
3983
|
-
if (
|
|
4856
|
+
for (const i of items) report.notified.push([i.sub.task_id, i.ev.kind, digest.to]);
|
|
4857
|
+
if (kind === 'summary') (report.notices_collapsed ??= []).push([digest.to, items.length, cards.length]);
|
|
3984
4858
|
for (const { sub } of subs) noticeWait(board, sub, report);
|
|
3985
4859
|
} catch (error) {
|
|
3986
4860
|
for (const { sub } of subs) noticeWait(board, sub, report, String(error.message).slice(-200));
|
|
@@ -4028,7 +4902,11 @@ async function notify(board, fleet, report, entry) {
|
|
|
4028
4902
|
await file(digest, state, { cards: rest, body, subject: numbered(boardKey(board.root, board.slug), null, `${count} updates on ${rest.length} cards`), kind: 'summary' });
|
|
4029
4903
|
}
|
|
4030
4904
|
}
|
|
4031
|
-
|
|
4905
|
+
const quietPending = (db, { sub, through }) => Boolean(db.prepare(`SELECT 1 FROM kanban_notify_subs ${where} AND created_at = ? AND last_event_id < ?`)
|
|
4906
|
+
.get(...identityOf(sub), sub.created_at, through));
|
|
4907
|
+
if (view(board, db => quiet.some(item => quietPending(db, item)))) tx(board, db => {
|
|
4908
|
+
for (const item of quiet) if (quietPending(db, item)) forward(db, item.sub, item.through);
|
|
4909
|
+
});
|
|
4032
4910
|
// a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
|
|
4033
4911
|
// work is over, and its session may never run again), anyone's when it is archived
|
|
4034
4912
|
for (const { sub, role, task, kinds } of read) {
|
|
@@ -4047,86 +4925,6 @@ async function notify(board, fleet, report, entry) {
|
|
|
4047
4925
|
}
|
|
4048
4926
|
}
|
|
4049
4927
|
|
|
4050
|
-
// Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
|
|
4051
|
-
|
|
4052
|
-
/**
|
|
4053
|
-
* A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
|
|
4054
|
-
* `serve` hosts none, as a Hermes CLI without its gateway delivers none). Answers what was sent and what failed.
|
|
4055
|
-
*/
|
|
4056
|
-
export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
4057
|
-
const sent = [];
|
|
4058
|
-
// what a chat is told is the workflow's (notify.chat)
|
|
4059
|
-
const entry = await loadWorkflow(root);
|
|
4060
|
-
const kinds = heardBy(entry, 'chat');
|
|
4061
|
-
if (!kinds.length) return sent;
|
|
4062
|
-
for (const slug of listBoards(root)) {
|
|
4063
|
-
const board = openBoard(root, slug);
|
|
4064
|
-
board.label = boardLabel(root, slug);
|
|
4065
|
-
try {
|
|
4066
|
-
for (const sub of subscriptions(board.db)) {
|
|
4067
|
-
const adapter = adapters?.[sub.platform];
|
|
4068
|
-
if (sub.platform === 'supercode' || !adapter) continue;
|
|
4069
|
-
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);
|
|
4070
|
-
if (!events.length) continue;
|
|
4071
|
-
const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug) } : t; });
|
|
4072
|
-
let cursor = Number(sub.last_event_id);
|
|
4073
|
-
let failed = false;
|
|
4074
|
-
for (const ev of events) {
|
|
4075
|
-
const payload = json(ev.payload, {}) ?? {};
|
|
4076
|
-
const text = noticeOf(entry, 'chat', ev.kind, task, payload);
|
|
4077
|
-
if (text) {
|
|
4078
|
-
// a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
|
|
4079
|
-
const files = ev.kind === 'completed' ? artifactsOf(payload, task).map((path) => ({ path })) : [];
|
|
4080
|
-
const mode = sub.delivery_mode || 'notify';
|
|
4081
|
-
// `wake`/`notify+wake`: the chat's own session gets the event as a turn (Hermes's wake), through the daemon
|
|
4082
|
-
if (mode !== 'notify' && wake) {
|
|
4083
|
-
try { await wake(sub, `${slug !== 'default' ? `[${slug}] ` : ''}${text}`); } catch (error) { log('warn', `board ${slug}: waking ${sub.platform}:${sub.chat_id} for ${sub.task_id} failed: ${error.message}`); }
|
|
4084
|
-
}
|
|
4085
|
-
if (mode === 'wake') { sent.push([sub.task_id, ev.kind, `${sub.platform}:${sub.chat_id} (wake)`]); cursor = ev.id; continue; }
|
|
4086
|
-
try {
|
|
4087
|
-
await adapter.send({ kind: 'explicit', platform: sub.platform, chat_id: sub.chat_id, ...(sub.thread_id ? { thread_id: sub.thread_id } : {}) }, { text: `${slug !== 'default' ? `[${slug}] ` : ''}${text}`, ...(files.length ? { files } : {}) });
|
|
4088
|
-
sent.push([sub.task_id, ev.kind, `${sub.platform}:${sub.chat_id}`]);
|
|
4089
|
-
} catch (error) {
|
|
4090
|
-
failed = true;
|
|
4091
|
-
log('warn', `board ${slug}: a notice for ${sub.task_id} to ${sub.platform}:${sub.chat_id} failed: ${error.message}`);
|
|
4092
|
-
break;
|
|
4093
|
-
}
|
|
4094
|
-
}
|
|
4095
|
-
cursor = ev.id;
|
|
4096
|
-
}
|
|
4097
|
-
tx(board, (db) => {
|
|
4098
|
-
const where = 'WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?';
|
|
4099
|
-
const key = [sub.task_id, sub.platform, sub.chat_id, sub.thread_id ?? ''];
|
|
4100
|
-
db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...key, cursor);
|
|
4101
|
-
const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
|
|
4102
|
-
if (failures >= MAX_SEND_FAILURES) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...key);
|
|
4103
|
-
else if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...key);
|
|
4104
|
-
if (task?.status === 'archived' && !failed) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...key);
|
|
4105
|
-
});
|
|
4106
|
-
}
|
|
4107
|
-
} finally {
|
|
4108
|
-
board.close();
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4111
|
-
return sent;
|
|
4112
|
-
}
|
|
4113
|
-
|
|
4114
|
-
/** A completed card's artifact files: its completion's `artifacts`, then absolute paths its summary or result names that exist. */
|
|
4115
|
-
function artifactsOf(payload, task) {
|
|
4116
|
-
const seen = new Set();
|
|
4117
|
-
const out = [];
|
|
4118
|
-
const add = (p) => {
|
|
4119
|
-
const path = String(p ?? '').replace(/^~(?=\/)/, process.env.HOME ?? '~');
|
|
4120
|
-
if (!path.startsWith('/') || seen.has(path)) return;
|
|
4121
|
-
try { if (!statSync(path).isFile()) return; } catch { return; }
|
|
4122
|
-
seen.add(path);
|
|
4123
|
-
out.push(path);
|
|
4124
|
-
};
|
|
4125
|
-
for (const p of Array.isArray(payload.artifacts) ? payload.artifacts : []) add(p);
|
|
4126
|
-
for (const text of [payload.summary, task?.result]) for (const m of String(text ?? '').matchAll(/(?:^|\s)(~?\/[^\s'"`]+\.[A-Za-z0-9]{1,8})/g)) add(m[1]);
|
|
4127
|
-
return out;
|
|
4128
|
-
}
|
|
4129
|
-
|
|
4130
4928
|
// ------------------------------------------------------------------ hooks
|
|
4131
4929
|
|
|
4132
4930
|
// Hermes's lifecycle hooks, from the board's own events (so a change any writer made fires them, Hermes's CLI's too)
|
|
@@ -4369,9 +5167,8 @@ function outcomeTicked(body) {
|
|
|
4369
5167
|
/** An opened PR asks for the declared review transition once the arc's tasks are closed. */
|
|
4370
5168
|
// A task written in the card's own Tasks section that did not become a subtask holds its review too.
|
|
4371
5169
|
/** The arc's session still in a turn: its review, and the close of that session the request brings (end_run), wait for
|
|
4372
|
-
* the turn to end
|
|
4373
|
-
* still
|
|
4374
|
-
* 2026-10-04, and the close ended it). A session the reading cannot place waits on nothing.
|
|
5170
|
+
* the turn to end so work in flight is never cut. The reviewer owns a separate checkout (ADR 0262); the transition's
|
|
5171
|
+
* close still must not interrupt the author's turn. A session the reading cannot place waits on nothing.
|
|
4375
5172
|
*
|
|
4376
5173
|
* A Codex session with an active thread goal is never read between turns: the goal starts the next turn the moment one
|
|
4377
5174
|
* ends (t_374d7f70, codex 01a108b7, 2026-10-14). For it the turn boundary is the one its harness records: the open turn
|
|
@@ -4384,9 +5181,10 @@ async function turnInFlight(board, id, fleet, heldSince = null) {
|
|
|
4384
5181
|
if (!fleet?.ok) return null;
|
|
4385
5182
|
const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
|
|
4386
5183
|
if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
|
|
4387
|
-
|
|
5184
|
+
await readNativeHome(session);
|
|
5185
|
+
let live = fleetSessionRow(fleet, session);
|
|
4388
5186
|
if (live?.turn == null && session.surface === 'native') try {
|
|
4389
|
-
const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, session
|
|
5187
|
+
const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, nativeHomes(session)), session).row?.activity;
|
|
4390
5188
|
live = activity ? { turn: activity.turn ?? null, turn_trigger: activity.evidence?.turn_trigger ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null } : live;
|
|
4391
5189
|
} catch { /* unread: waits on nothing */ }
|
|
4392
5190
|
const turn = live?.turn ?? null;
|
|
@@ -4409,25 +5207,175 @@ function reviewDueKey(board, id, payload) {
|
|
|
4409
5207
|
|
|
4410
5208
|
const PASSED_WAITS = 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`';
|
|
4411
5209
|
|
|
5210
|
+
/** The latest wait recorded for queued review `queued` (`review_queue_waiting`), with its event id, or null. */
|
|
5211
|
+
function queueWait(db, id, queued) {
|
|
5212
|
+
const row = latestEvent(db, id, ['review_queue_waiting'], 'id,payload', "json_extract(payload,'$.queued_event')=?", [queued]);
|
|
5213
|
+
return row ? { ...(json(row.payload, {}) ?? {}), event: row.id } : null;
|
|
5214
|
+
}
|
|
5215
|
+
/** A queued request's PR as discovery last saw it: what a wait on that PR compares. */
|
|
5216
|
+
const observedOf = pr => pr ? { state: pr.state ?? null, head: pr.headRefOid ?? null, draft: Boolean(pr.isDraft), base: pr.baseRefName ?? null } : null;
|
|
5217
|
+
const sameObservation = (observed, pr) => JSON.stringify(observed ?? null) === JSON.stringify(observedOf(pr));
|
|
5218
|
+
// Refusal kinds (ci.mjs) by the input that can change them (ADR 0252).
|
|
5219
|
+
const PR_STATE_REFUSALS = new Set(['moved', 'draft', 'state', 'release']);
|
|
5220
|
+
const SOURCE_REFUSALS = new Set(['undeclared', 'head', 'unpushed', 'uncommitted']);
|
|
5221
|
+
const FINAL_REFUSALS = new Set(['foreign', 'judged']);
|
|
5222
|
+
/** What a failed admission waits on (ADR 0252), or `{ refused }` for a request that can never be admitted. */
|
|
5223
|
+
async function admissionWait(result, url, seen) {
|
|
5224
|
+
const kinds = Array.isArray(result?.refusals) ? result.refusals.map(refusal => refusal?.kind) : [];
|
|
5225
|
+
if (kinds.some(kind => FINAL_REFUSALS.has(kind))) return { refused: kinds.filter(kind => FINAL_REFUSALS.has(kind)) };
|
|
5226
|
+
if (kinds.length && kinds.every(kind => PR_STATE_REFUSALS.has(kind))) return { on: 'pr', observed: observedOf(seen) };
|
|
5227
|
+
if (kinds.length && kinds.every(kind => PR_STATE_REFUSALS.has(kind) || SOURCE_REFUSALS.has(kind))) return { on: 'source', observed: observedOf(seen) };
|
|
5228
|
+
if (Number(result?.retry_at) > 0) return { on: 'budget', until: Number(result.retry_at) };
|
|
5229
|
+
return { on: 'list', listed_at: url ? await openListObservedAt(url) : 0 };
|
|
5230
|
+
}
|
|
5231
|
+
/** Whether the one input a recorded wait names has changed since it was recorded. */
|
|
5232
|
+
async function waitOver(board, id, wait, url, seen) {
|
|
5233
|
+
if (wait.on === 'pr') return !sameObservation(wait.observed, seen);
|
|
5234
|
+
if (wait.on === 'source') return !sameObservation(wait.observed, seen) || Boolean(view(board, db => redeclaredSince(db, id, wait.event)));
|
|
5235
|
+
if (wait.on === 'budget') return Date.now() >= Number(wait.until);
|
|
5236
|
+
if (wait.on === 'list') return (url ? await openListObservedAt(url) : 0) > Number(wait.listed_at ?? 0);
|
|
5237
|
+
return true;
|
|
5238
|
+
}
|
|
5239
|
+
function recordQueueWait(db, id, queued, reason, wait) {
|
|
5240
|
+
const prior = queueWait(db, id, queued);
|
|
5241
|
+
const { event: _event, queued_event: _queued, reason: priorReason, ...priorWait } = prior ?? {};
|
|
5242
|
+
if (prior && priorReason === reason && JSON.stringify(priorWait) === JSON.stringify(wait)) return;
|
|
5243
|
+
event(db, id, 'review_queue_waiting', { queued_event: queued, reason, ...wait });
|
|
5244
|
+
}
|
|
5245
|
+
/** A queued PR that discovery sees merged or closed unmerged is settled once a fresh reading confirms it. */
|
|
5246
|
+
async function settleQueuedPullRequest(board, id, url, report) {
|
|
5247
|
+
const confirmed = await discoverPullRequest(url, { fresh: true });
|
|
5248
|
+
const disposition = confirmed.state === 'MERGED' ? 'merged' : confirmed.state === 'CLOSED' ? 'closed' : null;
|
|
5249
|
+
if (!disposition) { report.queued.push([id, `${url} reads ${confirmed.state} on a fresh reading; its queued requests remain pending`]); return; }
|
|
5250
|
+
const settled = tx(board, db => {
|
|
5251
|
+
const ids = [];
|
|
5252
|
+
for (const request of pendingReviewRequests(db, id)) {
|
|
5253
|
+
const named = [].concat(request.pr ?? []);
|
|
5254
|
+
if (named.length !== 1 || prIdentity(named[0]) !== prIdentity(url)) continue;
|
|
5255
|
+
event(db, id, 'review_queue_settled', { queued_event: request.id, disposition, pr: url, head: confirmed.headRefOid });
|
|
5256
|
+
ids.push(request.id);
|
|
5257
|
+
}
|
|
5258
|
+
// a queue settled empty closes the reviewer its last verdict kept (ADR 0019)
|
|
5259
|
+
if (ids.length && !pendingReviewRequests(db, id).length) closeKeptReviewer(db, id);
|
|
5260
|
+
return ids;
|
|
5261
|
+
});
|
|
5262
|
+
if (settled.length) (report.queue_settled ??= []).push([id, url, disposition, settled]);
|
|
5263
|
+
}
|
|
5264
|
+
|
|
4412
5265
|
/** Review selection shared by rounds and read-only inspection (`dry: true`). */
|
|
4413
|
-
export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
|
|
5266
|
+
export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null, held = null, lanes = null } = {}) {
|
|
5267
|
+
// Capacity for an arc's next review, from this round's holdings: a queue whose review could not start reads nothing.
|
|
5268
|
+
const caps = held && lanes ? capacity(entry.workflow.dispatch.capacity, held, evaluate) : null;
|
|
5269
|
+
const reviewCapacity = caps ? (db, task) => {
|
|
5270
|
+
const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
|
|
5271
|
+
const transition = transitionsFor(entry.workflow, statusOf(db, task), 'request_review').find(t => t.target);
|
|
5272
|
+
const target = transition ? String(templateValue(transition.target, scope)) : null;
|
|
5273
|
+
const spec = target ? runSpec(entry, target, task) : null;
|
|
5274
|
+
const profile = spec ? evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
|
|
5275
|
+
const lane = profile ? lanes[profile] : null;
|
|
5276
|
+
if (!lane) return null;
|
|
5277
|
+
const candidate = { card: task.id, board: board.slug, machine: executionHost(lane, task.machine, localMachine(), normalMachine), assignee: profile,
|
|
5278
|
+
dispatch: true, slot: String(templateValue(spec.session ?? 'worker', scope) ?? 'worker'), status: target, stored: target,
|
|
5279
|
+
model: lane.model ?? held.defaults?.[lane.harness] ?? null, harness: lane.harness ?? null };
|
|
5280
|
+
return caps.admit(candidate, { ...scope, lane, run: { surface: lane.surface, local: candidate.machine === localMachine() } }, [candidate])?.why ?? null;
|
|
5281
|
+
} : null;
|
|
4414
5282
|
const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
4415
5283
|
WHERE c.subtask_of IS NULL AND c.every IS NULL
|
|
4416
5284
|
${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
|
|
4417
5285
|
// Review waits only someone's act clears, and the cards read this round in a state that is no such wait (surfaceReviewStalls)
|
|
4418
5286
|
const stalls = [], settled = [];
|
|
4419
5287
|
for (const { id } of candidates) {
|
|
5288
|
+
// The arc's one review queue (ADR 0252), oldest first. Every local check comes before any provider read: the arc's
|
|
5289
|
+
// status and holds, its reviewer free (no review runs), capacity for that review and no native turn in flight. Only
|
|
5290
|
+
// the round that can admit an entry reads it fresh; an entry refused for its PR's own state waits on that
|
|
5291
|
+
// observation and is read again only when discovery sees its PR change (ADR 0247).
|
|
5292
|
+
if (view(board, db => pendingReviewRequests(db, id).length)) {
|
|
5293
|
+
const local = view(board, db => {
|
|
5294
|
+
const task = getTask(db, id);
|
|
5295
|
+
if (!task) return { why: 'the card no longer exists' };
|
|
5296
|
+
const status = statusOf(db, task), card = scopeOf(db, board, task, { params: entry.params, lazy: true }).card;
|
|
5297
|
+
if (status === 'review') return { why: "the arc's review in progress" };
|
|
5298
|
+
if (status !== 'running' || card.backlog || card.dispatch_hold || card.paused || card.blocked_by.length || card.archived) return { why: "the arc's status or holds" };
|
|
5299
|
+
return { task, why: reviewCapacity?.(db, task) ?? null };
|
|
5300
|
+
});
|
|
5301
|
+
const why = local.why ?? (await turnInFlight(board, id, fleet))?.why ?? null;
|
|
5302
|
+
if (why) {
|
|
5303
|
+
report.queued.push([id, `its queued reviews wait on ${why}`]);
|
|
5304
|
+
settled.push(id); continue;
|
|
5305
|
+
}
|
|
5306
|
+
let admitted = false;
|
|
5307
|
+
for (const queued of view(board, db => pendingReviewRequests(db, id))) {
|
|
5308
|
+
await yieldToOwner();
|
|
5309
|
+
if (stopping) return;
|
|
5310
|
+
if (!view(board, db => pendingReviewRequests(db, id).some(row => row.id === queued.id))) continue;
|
|
5311
|
+
const url = [].concat(queued.pr ?? [])[0] ?? null;
|
|
5312
|
+
let seen = null;
|
|
5313
|
+
try { seen = url ? await discoverQueuedPullRequest(url) : null; }
|
|
5314
|
+
catch (error) { report.queued.push([id, `queued review ${queued.id} waits on its PR's observation: ${String(error.message).slice(-200)}`]); continue; }
|
|
5315
|
+
if (seen && ['MERGED', 'CLOSED'].includes(seen.state)) {
|
|
5316
|
+
if (dry) (report.would_settle ??= []).push({ id, pr: url, disposition: seen.state === 'MERGED' ? 'merged' : 'closed' });
|
|
5317
|
+
else await settleQueuedPullRequest(board, id, url, report);
|
|
5318
|
+
continue;
|
|
5319
|
+
}
|
|
5320
|
+
const waiting = view(board, db => queueWait(db, id, queued.id));
|
|
5321
|
+
if (waiting && !await waitOver(board, id, waiting, url, seen)) {
|
|
5322
|
+
report.queued.push([id, `queued review ${queued.id} waits on its ${waiting.on === 'pr' ? 'PR' : waiting.on}: ${waiting.reason}`]);
|
|
5323
|
+
if (['budget', 'list'].includes(waiting.on)) break;
|
|
5324
|
+
continue;
|
|
5325
|
+
}
|
|
5326
|
+
if (dry) {
|
|
5327
|
+
(report.would_review ??= []).push({ id, prs: [].concat(queued.pr ?? []), heads: [].concat(queued.pr_head ?? []), queued_event: queued.id });
|
|
5328
|
+
admitted = true; break;
|
|
5329
|
+
}
|
|
5330
|
+
// The board acts as itself; the request's sender and requester travel in its payload, never as the board's actor.
|
|
5331
|
+
const previousActor = actorOf();
|
|
5332
|
+
let result;
|
|
5333
|
+
try {
|
|
5334
|
+
setActor(boardAddress(board.label));
|
|
5335
|
+
result = await requestReview(board, id, { ...queued, roles: Array.isArray(queued.sender) ? queued.sender : [],
|
|
5336
|
+
requester: queued.requester ?? queued.by ?? null, expectedRunId: local.task.current_run_id, queuedEvent: queued.id });
|
|
5337
|
+
} catch (error) { result = { reason: String(error.message).slice(-300), retry_at: error.retry_at ?? null, refusals: error.refusals ?? null }; }
|
|
5338
|
+
finally { setActor(previousActor); }
|
|
5339
|
+
if (!result.routed && !result.accepted && !result.pending && !result.queued && result.moved && result.to === 'review') {
|
|
5340
|
+
effects.push(...(result.effects ?? []));
|
|
5341
|
+
(report.review_requested ??= []).push({ id, prs: [].concat(queued.pr ?? []), heads: [].concat(queued.pr_head ?? []), queued_event: queued.id });
|
|
5342
|
+
admitted = true; break;
|
|
5343
|
+
}
|
|
5344
|
+
// A failed admission waits on the one input that can change its answer, never on the round; one that can never
|
|
5345
|
+
// be admitted settles refused by name (ADR 0252). A budget or transport wait holds every later request too.
|
|
5346
|
+
const reason = result.reason ?? result.disposition ?? 'its candidate could not be admitted';
|
|
5347
|
+
const wait = await admissionWait(result, url, seen);
|
|
5348
|
+
if (wait.refused) {
|
|
5349
|
+
tx(board, db => {
|
|
5350
|
+
event(db, id, 'review_queue_settled', { queued_event: queued.id, disposition: 'refused', refusals: wait.refused, reason, pr: url });
|
|
5351
|
+
if (!pendingReviewRequests(db, id).length) closeKeptReviewer(db, id);
|
|
5352
|
+
});
|
|
5353
|
+
report.queued.push([id, `queued review ${queued.id} settled refused: ${reason}`]);
|
|
5354
|
+
continue;
|
|
5355
|
+
}
|
|
5356
|
+
report.queued.push([id, `queued review ${queued.id} waits on its ${wait.on === 'pr' ? 'PR' : wait.on}: ${reason}`]);
|
|
5357
|
+
tx(board, db => recordQueueWait(db, id, queued.id, reason, wait));
|
|
5358
|
+
if (['budget', 'list'].includes(wait.on)) break;
|
|
5359
|
+
}
|
|
5360
|
+
settled.push(id);
|
|
5361
|
+
if (admitted) continue;
|
|
5362
|
+
// The final review starts only once nothing is pending.
|
|
5363
|
+
if (view(board, db => pendingReviewRequests(db, id).length)) { report.queued.push([id, 'final review waits on pending PR review requests']); continue; }
|
|
5364
|
+
}
|
|
4420
5365
|
const eligible = db => {
|
|
4421
5366
|
const task = getTask(db, id);
|
|
4422
5367
|
if (!task) return false;
|
|
4423
5368
|
// a status the platform cannot request a review from needs no scope built to say so
|
|
4424
5369
|
if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform'))) return false;
|
|
4425
5370
|
const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
|
|
4426
|
-
if (scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
|
|
5371
|
+
if (scope.card.backlog || scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
|
|
4427
5372
|
// A person's card needs no review: it closes on that person's completion.
|
|
4428
5373
|
if (scope.card.person) return false;
|
|
4429
|
-
//
|
|
4430
|
-
const
|
|
5374
|
+
// Final review closes recorded work: a taskless card has not planned its work yet, even if its branch has a PR.
|
|
5375
|
+
const subtasks = scope.card.subtasks;
|
|
5376
|
+
if (!subtasks.length) return false;
|
|
5377
|
+
// An open task holds the final review, and says so, paused or not: the review comes when every task is done or cancelled.
|
|
5378
|
+
const open = subtasks.filter(t => !['done', 'cancelled'].includes(t.status));
|
|
4431
5379
|
return open.length ? open : true;
|
|
4432
5380
|
};
|
|
4433
5381
|
const ready = view(board, eligible);
|
|
@@ -4460,20 +5408,45 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
4460
5408
|
}
|
|
4461
5409
|
// An arc whose every PR merged at the head its PR review passed (`pr_reviewed`) still gets its final review, of its
|
|
4462
5410
|
// Acceptance against them, once that is due (settledReviewDue).
|
|
4463
|
-
const settledOnly = !payload.no_code && !payload.pr.length &&
|
|
4464
|
-
if (settledOnly && !settledReviewDue(board, id)) { report.queued.push([id, passedReview(board, id) ? PASSED_WAITS : `review waits: every PR of the arc (${payload.pr_reviewed.map((p) => p.url).join(', ')}) merged at the head its PR review passed, and no final review is due since its last one`]); continue; }
|
|
4465
|
-
|
|
4466
|
-
if (!settledOnly && !payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
5411
|
+
const settledOnly = !payload.no_code && !payload.pr.length && Boolean(payload.pr_reviewed?.length);
|
|
5412
|
+
if (settledOnly && !settledReviewDue(board, id, payload.pr_reviewed)) { report.queued.push([id, passedReview(board, id) ? PASSED_WAITS : `review waits: every PR of the arc (${payload.pr_reviewed.map((p) => p.url).join(', ')}) merged at the head its PR review passed, and no final review is due since its last one`]); continue; }
|
|
5413
|
+
if (!settledOnly && !payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
4467
5414
|
// An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
|
|
4468
5415
|
// session's done request and the manager's or creator's `complete`, and the round says that (volter-org
|
|
4469
5416
|
// t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
|
|
4470
5417
|
if (passedReview(board, id)) { report.queued.push([id, PASSED_WAITS]); continue; }
|
|
4471
5418
|
// a review the manager returned waits on its coder's next pushed head, which the coder is due to push
|
|
4472
|
-
if (returnedReview(board, id)) { report.queued.push([id, 'review waits: the manager returned it from review with no verdict; the next review starts once a pushed PR head changes']); continue; }
|
|
4473
|
-
//
|
|
5419
|
+
if (returnedReview(board, id)) { report.queued.push([id, 'review waits: the manager returned it from review with no verdict; the next review starts once a pushed PR head changes or the arc re-declares its sources']); continue; }
|
|
5420
|
+
// A PR whose PR review passed at its head waits on its coder's merge at that head, which settles it: an act due
|
|
5421
|
+
// from the arc's implementer, whatever its tasks say, so its session is woken and told to merge (tellMergeDue).
|
|
5422
|
+
// Only an arc with no implementer session to tell, or one told MERGE_TOLD_WAIT_SECONDS ago that has not merged,
|
|
5423
|
+
// is the manager's to hear.
|
|
4474
5424
|
const passedOpen = payload.pr.filter((url, index) => prReviewedHead(board, id, payload.pr_head[index]));
|
|
4475
|
-
|
|
4476
|
-
|
|
5425
|
+
if (passedOpen.length) {
|
|
5426
|
+
const due = passedOpen.map((url) => { const head = payload.pr_head[payload.pr.indexOf(url)]; return { url, head, ...prDisposition(board,id,url,head) }; });
|
|
5427
|
+
// read first (a dry plan and a round with nothing new to tell write nothing), then file in a write of its own
|
|
5428
|
+
const read = view(board, (db) => mergeDue(db, id, due));
|
|
5429
|
+
const told = read?.address && !read.prior && !dry ? tx(board, (db) => tellMergeDue(db, board, id, due)) : read && { ...read, at: read.prior?.at ?? null };
|
|
5430
|
+
const waiting = due.map(d => `${d.url} passed its PR review and waits on ${d.disposition === 'release-only' ? `its verified release readback on ${d.target}` : 'its merge'} at its reviewed head`).join('; ');
|
|
5431
|
+
if (told?.address && !told.overdue) {
|
|
5432
|
+
report.queued.push([id, `review waits: ${waiting}; its implementer ${told.address} is ${told.now ? 'woken and told' : read.prior ? 'told' : 'to be woken and told'} to settle it`]);
|
|
5433
|
+
if (told.now) (report.merges_told ??= []).push([id, told.address]);
|
|
5434
|
+
settled.push(id);
|
|
5435
|
+
continue;
|
|
5436
|
+
}
|
|
5437
|
+
// The pass ends the reviewer's run in the round's own review step, before startCards gives the implementer its
|
|
5438
|
+
// next run: an arc whose pass is newer than MERGE_TOLD_WAIT_SECONDS with no implementer session yet is waiting
|
|
5439
|
+
// on that start, not on anyone's act.
|
|
5440
|
+
if (!told?.address && now() - lastPrReviewPass(board, id) <= MERGE_TOLD_WAIT_SECONDS) {
|
|
5441
|
+
report.queued.push([id, `review waits: ${waiting}; its implementer's next run is told to settle it once it starts`]);
|
|
5442
|
+
continue;
|
|
5443
|
+
}
|
|
5444
|
+
const why = told?.address ? `${waiting}; its implementer ${told.address} was told to settle it at ${new Date(told.at * 1000).toISOString()} and has not` : `${waiting}; the arc has no implementer session to tell`;
|
|
5445
|
+
report.queued.push([id, `review waits: ${why}`]);
|
|
5446
|
+
if (!dry) stalls.push({ id, key: `merge:${due.map((d) => `${d.url}@${d.head}`).sort().join(' ')}:${told?.address ?? 'none'}`, why: `its review waits: ${why}.` });
|
|
5447
|
+
continue;
|
|
5448
|
+
}
|
|
5449
|
+
const why = `every 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`;
|
|
4477
5450
|
report.queued.push([id, `review waits: ${why}`]);
|
|
4478
5451
|
// A verdict other than a pass that nothing answered waits on an act
|
|
4479
5452
|
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.` });
|
|
@@ -4503,7 +5476,7 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
4503
5476
|
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
4504
5477
|
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
4505
5478
|
const observed = await withFreshReviewReads(async () => {
|
|
4506
|
-
const candidate = await openReviewPullRequests(board, id, { fresh: true,
|
|
5479
|
+
const candidate = await openReviewPullRequests(board, id, { fresh: true, repositories: payload.review_repositories ?? [] })
|
|
4507
5480
|
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id, { fresh: true }) : null);
|
|
4508
5481
|
const evidence = !candidate || candidate.waits ? null : candidate.no_code
|
|
4509
5482
|
? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
|
|
@@ -4542,6 +5515,47 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
4542
5515
|
if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
|
|
4543
5516
|
}
|
|
4544
5517
|
|
|
5518
|
+
/** How long an implementer told to merge a passed PR has before its arc's wait is the manager's to hear. */
|
|
5519
|
+
const MERGE_TOLD_WAIT_SECONDS = 1800;
|
|
5520
|
+
/** When the arc's latest PR review pass was recorded (seconds), or 0. */
|
|
5521
|
+
const lastPrReviewPass = (board, id) => Number(board.db.prepare("SELECT MAX(created_at) AS at FROM task_events WHERE task_id = ? AND kind = 'pr_review_pass'").get(id)?.at ?? 0);
|
|
5522
|
+
|
|
5523
|
+
/**
|
|
5524
|
+
* Who is told that `due` (each a PR and the head its PR review passed) waits on its merge: the arc's current, unended
|
|
5525
|
+
* run's worker that the board has not closed or been asked to close, as a read. `prior` is the telling already filed to
|
|
5526
|
+
* that session for those heads (a `merge_due_told` whose notice was filed, not dropped as not required). Answers
|
|
5527
|
+
* { address, run, key, prior, overdue }, or null when there is no such session.
|
|
5528
|
+
*/
|
|
5529
|
+
function mergeDue(db, id, due) {
|
|
5530
|
+
const run = currentRun(db, id), session = run?.metadata?.supercode, address = addressOf(session);
|
|
5531
|
+
if (!run || run.ended_at != null || !address || (session.slot ?? 'worker') !== 'worker' || session.close_requested || session.close_confirmed_at || session.released || detachedSession(db, session)) return null;
|
|
5532
|
+
const key = `${due.map((d) => `${d.url}@${d.head}:${d.disposition ?? 'merge'}:${d.event ?? ''}`).sort().join(' ')} ${address}`;
|
|
5533
|
+
const told = db.prepare(`SELECT t.id, t.created_at FROM task_events t WHERE t.task_id = ? AND t.kind = 'merge_due_told' AND json_extract(t.payload, '$.key') = ?
|
|
5534
|
+
AND NOT EXISTS (SELECT 1 FROM task_events n WHERE n.task_id = t.task_id AND n.kind = 'session_notice_not_required' AND n.id > t.id AND json_extract(n.payload, '$.verb') = 'merge_due'
|
|
5535
|
+
AND n.run_id IS t.run_id) ORDER BY t.id DESC LIMIT 1`).get(id, key);
|
|
5536
|
+
const prior = told ? { at: Number(told.created_at) } : null;
|
|
5537
|
+
return { address, run, key, prior, overdue: Boolean(prior) && now() - prior.at > MERGE_TOLD_WAIT_SECONDS };
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
/**
|
|
5541
|
+
* Tell the arc's implementer session (mergeDue) that `due` waits on its merge at its reviewed heads, in the caller's
|
|
5542
|
+
* transaction: a session notice through the effect queue (sessionNotice files it with a wake), once per set of heads and
|
|
5543
|
+
* session, recorded as `merge_due_told`. Answers { address, at, now, overdue }, or null when there is no such session.
|
|
5544
|
+
*/
|
|
5545
|
+
function tellMergeDue(db, board, id, due) {
|
|
5546
|
+
const task = getTask(db, id), read = mergeDue(db, id, due);
|
|
5547
|
+
if (!task || !read) return null;
|
|
5548
|
+
if (read.prior) return { address: read.address, at: read.prior.at, now: false, overdue: read.overdue };
|
|
5549
|
+
const lines = due.map((d) => d.disposition === 'release-only'
|
|
5550
|
+
? `- ${d.url}: its PR review passed at ${d.head}. It is release-only; record the verified release readback for ${d.target} at that head through workflow comment --pr ${d.url} --pr-head ${d.head} --release-target ${d.target} --released-artifact ${d.head} --release-evidence POINTER --reason TEXT (a non-machine target requires the manager's --attest-release). Its release permission is separate.`
|
|
5551
|
+
: `- ${d.url}: its PR review passed at ${d.head}. Merge it at that head now: gh pr merge ${d.url} --merge --match-head-commit ${d.head}`);
|
|
5552
|
+
const text = `${numbered(boardKey(board.root, board.slug), cardNumber(db, id), id, noticeName(task))} ${task.title}: a PR review passed and its reviewed obligation is outstanding.\n${lines.join('\n')}\nOnce every PR is merged or settled by its verified release-only readback and every task is closed, the board starts the arc's final review of its Acceptance.`;
|
|
5553
|
+
const source = event(db, id, 'merge_due_told', { key: read.key, address: read.address, prs: due }, read.run.id);
|
|
5554
|
+
db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify({ kind: 'session_notice', id, runId: read.run.id, address: read.address, text, sourceEvent: source, verb: 'merge_due',
|
|
5555
|
+
messageKey: `board-merge-due-${id}-${createHash('sha256').update(read.key).digest('hex').slice(0, 16)}` }));
|
|
5556
|
+
return { address: read.address, at: now(), now: true, overdue: false };
|
|
5557
|
+
}
|
|
5558
|
+
|
|
4545
5559
|
/** The id of the arc's last review event (requested, changes requested, passed, voided, returned), or 0: a review wait's key
|
|
4546
5560
|
* carries it, so the wait a new verdict or request leaves is a new wait. */
|
|
4547
5561
|
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', 'review_returned')").get(id)?.id ?? 0;
|
|
@@ -4601,6 +5615,77 @@ function surfaceReviewStalls(board, entry, stalls, settled, report) {
|
|
|
4601
5615
|
});
|
|
4602
5616
|
}
|
|
4603
5617
|
|
|
5618
|
+
/** The board's record of each machine whose fleet record reads not current: since when, the error, whether it was told. */
|
|
5619
|
+
const FLEET_STALE_KEY = 'fleet_records_stale';
|
|
5620
|
+
/**
|
|
5621
|
+
* A machine whose fleet record stays not current reaches the board's manager through the board's own mail
|
|
5622
|
+
* (`board_message`, filed by the delivery pass's fileBoardMail), once per stretch. While its record is not current the
|
|
5623
|
+
* board launches nothing there and checks none of its sessions (readOne, launcherCheck), and before this only the
|
|
5624
|
+
* board's log said why: volter-desktop's record read not current from 09:22:56Z 2026-10-29, and a person noticed about
|
|
5625
|
+
* 20 minutes later.
|
|
5626
|
+
*
|
|
5627
|
+
* A stretch runs across rounds that read the machine not current without a break (a gap longer than an unread clock's,
|
|
5628
|
+
* max(3 × interval, idleQuiet / 2), starts a new one); once it has lasted the board's idle bound (`settings.idleQuiet`,
|
|
5629
|
+
* the same bound an unreadable session turn waits before its unknown diagnostic), the manager is told once, on the oldest open
|
|
5630
|
+
* card that names the machine (its own machine or its current run's), with every such card listed. Nothing is told
|
|
5631
|
+
* again until the machine reads current (which ends the stretch) and a new stretch lasts the bound. A machine no open
|
|
5632
|
+
* card names is kept untold. A round that read no fleet (no pane lanes) neither tells nor ends a stretch.
|
|
5633
|
+
* A reading this machine's daemon did not give at all (`fleet.ok` false: every machine reads unknown with that one
|
|
5634
|
+
* local error) is one stretch, this machine's, told once with every open card that names any machine; the other
|
|
5635
|
+
* machines' stretches are kept as they stood, neither told nor ended by it.
|
|
5636
|
+
*/
|
|
5637
|
+
export function surfaceStaleRecords(board, entry, fleet, settings, report) {
|
|
5638
|
+
const to = [].concat(entry.params.managers ?? []).find(Boolean);
|
|
5639
|
+
if (!to || !(fleet?.unknown instanceof Set)) return;
|
|
5640
|
+
const at = now(), gap = Math.max(3 * settings.interval, settings.idleQuiet / 2);
|
|
5641
|
+
let stored = {};
|
|
5642
|
+
// a board that never kept state has no table yet: it holds no stretch
|
|
5643
|
+
try { stored = view(board, (db) => json(db.prepare('SELECT value FROM supercode_board_state WHERE key = ?').get(FLEET_STALE_KEY)?.value, null)) ?? {}; } catch { /* none kept */ }
|
|
5644
|
+
const local = localMachine(), whole = fleet.ok === false;
|
|
5645
|
+
if (!whole && !fleet.unknown.size && !Object.keys(stored).length) return;
|
|
5646
|
+
const next = {}, due = [];
|
|
5647
|
+
// no reading at all: this machine's daemon is the one stretch; the others' stretches stand as they were, read again
|
|
5648
|
+
// (their seen_at moves on), so an outage longer than the gap neither restarts them nor tells them twice
|
|
5649
|
+
if (whole) for (const [machine, kept] of Object.entries(stored)) if (machine !== local) next[machine] = { ...kept, seen_at: at };
|
|
5650
|
+
for (const machine of whole ? [local] : fleet.unknown) {
|
|
5651
|
+
const kept = stored[machine];
|
|
5652
|
+
const since = kept && at - Number(kept.seen_at) <= gap && Number(kept.since) > 0 ? Number(kept.since) : at;
|
|
5653
|
+
const error = String((whole ? fleet.error : fleet.errors?.get(machine)) ?? fleet.error ?? 'no current record').split('\n')[0].slice(0, 300);
|
|
5654
|
+
// a stretch that changes kind (no reading at all, then this machine's own record not current) keeps its clock (the
|
|
5655
|
+
// machine was never current) but not its telling: what is told now is a different failure
|
|
5656
|
+
const told = kept?.told_at && Number(kept.since) === since && Boolean(kept.whole) === whole && Boolean(kept.targets) === Boolean(whole && fleet.targetsUnread);
|
|
5657
|
+
next[machine] = { since, seen_at: at, error, ...(whole ? { whole: true } : {}), ...(whole && fleet.targetsUnread ? { targets: true } : {}), ...(told ? { told_at: kept.told_at, told_on: kept.told_on } : {}) };
|
|
5658
|
+
if (!next[machine].told_at && at - since >= settings.idleQuiet) due.push(machine);
|
|
5659
|
+
}
|
|
5660
|
+
const iso = (seconds) => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
5661
|
+
tx(board, (db) => {
|
|
5662
|
+
db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
|
|
5663
|
+
const open = db.prepare(`SELECT t.id, t.current_run_id, c.machine FROM tasks t JOIN supercode_cards c ON c.task_id = t.id
|
|
5664
|
+
WHERE c.subtask_of IS NULL AND NOT COALESCE(c.archived, 0) AND t.status NOT IN ('done', 'cancelled', 'archived') ORDER BY t.created_at, t.id`).all();
|
|
5665
|
+
for (const machine of due) {
|
|
5666
|
+
const runMachine = (card) => card.current_run_id ? currentRun(db, card.id)?.metadata?.supercode?.machine ?? null : null;
|
|
5667
|
+
// this machine's daemon gave no reading: every card that names a machine waits on it
|
|
5668
|
+
const cards = open.filter((card) => next[machine].whole ? Boolean(card.machine || runMachine(card))
|
|
5669
|
+
: (card.machine && normalMachine(card.machine) === machine) || normalMachine(runMachine(card) ?? '') === machine);
|
|
5670
|
+
if (!cards.length) continue;
|
|
5671
|
+
const stale = next[machine], minutes = Math.round((at - stale.since) / 60);
|
|
5672
|
+
const names = cards.map((card) => { const task = getTask(db, card.id); return numbered(boardKey(board.root, board.slug), task?.number, card.id, noticeName(task)); });
|
|
5673
|
+
event(db, cards[0].id, 'board_message', { to, fleet_stale: `${machine}:${stale.since}`,
|
|
5674
|
+
text: stale.targets
|
|
5675
|
+
? `The board could not read its fleet targets since ${iso(stale.since)} (${minutes} min): ${stale.error}. Every machine reads not current: until a reading completes, the board launches nothing and checks no session anywhere. Open cards on machines: ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}.`
|
|
5676
|
+
: stale.whole
|
|
5677
|
+
? `This machine's (${machine}) daemon has given no fleet records since ${iso(stale.since)} (${minutes} min): ${stale.error}. Every machine reads not current: until it answers, the board launches nothing and checks no session anywhere. Open cards on machines: ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}. Told once for this stretch.`
|
|
5678
|
+
: `Machine ${machine}'s fleet record has not been current since ${iso(stale.since)} (${minutes} min): ${stale.error}. Until it is, the board launches nothing there and checks none of its sessions. Open cards on it: ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}. Told once for this stretch.` }, null);
|
|
5679
|
+
Object.assign(stale, { told_at: at, told_on: cards[0].id });
|
|
5680
|
+
(report.fleet_records_told ??= []).push([machine, iso(stale.since), cards[0].id]);
|
|
5681
|
+
}
|
|
5682
|
+
db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(FLEET_STALE_KEY, JSON.stringify(next));
|
|
5683
|
+
});
|
|
5684
|
+
const stretches = Object.entries(next);
|
|
5685
|
+
if (stretches.length) report.fleet_records_stale = stretches.map(([machine, stale]) => [machine, iso(stale.since), stale.told_at ? 'told' : 'untold', stale.error]);
|
|
5686
|
+
for (const machine of Object.keys(stored)) if (!next[machine] || (stored[machine].whole && !next[machine].whole)) (report.fleet_records_current ??= []).push(machine);
|
|
5687
|
+
}
|
|
5688
|
+
|
|
4604
5689
|
/**
|
|
4605
5690
|
* A cap's max for one of its groups, for the capacity view: what a card in that group would be held to. A max read
|
|
4606
5691
|
* from the card or its lane (a machine's or a profile's own capacity) is evaluated for that group's machine or profile.
|
|
@@ -4621,8 +5706,14 @@ const deliveries = new Map();
|
|
|
4621
5706
|
export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
|
|
4622
5707
|
// the controller's delivery pass, run here by the owner (no process of its own), never two at once for a home; the
|
|
4623
5708
|
// round never waits for it
|
|
4624
|
-
|
|
4625
|
-
|
|
5709
|
+
// Every pass is recorded, fast ones included (its start, end, duration and steps), and so is a round that started
|
|
5710
|
+
// none because one still ran: a delivery bar is read from these rows, never from the round count.
|
|
5711
|
+
if (deliveries.has(root)) {
|
|
5712
|
+
recordSlow({ kind: 'call', name: 'board delivery pass skipped', ms: 0, includeFast: true, extra: { running_since: deliveries.get(root).started } });
|
|
5713
|
+
return false;
|
|
5714
|
+
}
|
|
5715
|
+
const started = new Date().toISOString();
|
|
5716
|
+
const pass = timed('board delivery pass', () => ownCall(() => deliver({ root, slugs, controller: true }), 'a delivery pass'), { scope: 'delivery', includeFast: true }).then((reports) => {
|
|
4626
5717
|
for (const r of reports) {
|
|
4627
5718
|
if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
|
|
4628
5719
|
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a delivery for ${task} to ${to} waits: ${why}`);
|
|
@@ -4630,7 +5721,7 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
|
|
|
4630
5721
|
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(', ')}`);
|
|
4631
5722
|
}
|
|
4632
5723
|
}, (error) => error?.code === GENERATION_STOPPED ? log('info', `delivery pass not run: ${error.message}`) : log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
|
|
4633
|
-
deliveries.set(root, pass);
|
|
5724
|
+
deliveries.set(root, { pass, started });
|
|
4634
5725
|
return true;
|
|
4635
5726
|
}
|
|
4636
5727
|
|
|
@@ -4660,28 +5751,27 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
4660
5751
|
const entry = await loadWorkflow(root);
|
|
4661
5752
|
// the owner's controller delivers only for boards whose dispatching is on, read as each board is reached (a pass
|
|
4662
5753
|
// already under way when a board is switched off leaves it); `workflow deliver`, an operator's act, delivers as asked
|
|
4663
|
-
const boards =
|
|
5754
|
+
const boards = slugs ?? listBoards(root);
|
|
4664
5755
|
if (!boards.length) return [];
|
|
4665
5756
|
const fleet = await observeFleet();
|
|
4666
5757
|
fleet.noticeAlive = await noticeSessions();
|
|
4667
5758
|
const out = [];
|
|
4668
5759
|
for (const slug of boards) {
|
|
4669
|
-
if (controller && !dispatchingOf(root, slug).on) continue;
|
|
4670
5760
|
const report = { board: slug, ms: {}, mailed: [], queued: [], failed: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
|
|
4671
5761
|
let board;
|
|
4672
5762
|
try {
|
|
4673
5763
|
board = openBoard(root, slug);
|
|
4674
5764
|
board.label = boardLabel(root, slug);
|
|
4675
5765
|
setActor(boardAddress(board.label));
|
|
4676
|
-
|
|
4677
|
-
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; roundStep(`${slug} ${name}`); };
|
|
5766
|
+
const phase = phaseClock(report, `delivery ${slug}`);
|
|
4678
5767
|
// board mail and notices are filed in their receivers' mailboxes (local writes); the mailbox delivers them
|
|
4679
5768
|
// a controller's pass reads the board's switch before each phase and each effect: switched off meanwhile, what had
|
|
4680
5769
|
// not begun is left (an operator's `workflow deliver` delivers as asked)
|
|
4681
5770
|
const on = () => !controller || dispatchingOf(root, slug).on;
|
|
4682
5771
|
if (on()) { await fileBoardMail(board, report); phase('mail'); }
|
|
4683
5772
|
if (on()) { report.effects = await runEffects(board, [], { pending: true, eligible: controller ? on : null }); phase('effects'); }
|
|
4684
|
-
|
|
5773
|
+
// A due resume is announced even with dispatching off. This read/delivery never launches work.
|
|
5774
|
+
await notify(board, fleet, report, entry); phase('notify');
|
|
4685
5775
|
if (on()) { await synchronizeAgents(board, report, fleet); phase('agents'); }
|
|
4686
5776
|
report.effects_failed = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NOT NULL').get().n);
|
|
4687
5777
|
report.effects_waiting = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NULL').get().n);
|
|
@@ -4702,6 +5792,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
4702
5792
|
export async function closeEnded({ root, slugs, settings }) {
|
|
4703
5793
|
fleetRoot = root;
|
|
4704
5794
|
if (stopping || !slugs.length || boardCopyOf({ root })) return [];
|
|
5795
|
+
const entry = await loadWorkflow(root);
|
|
4705
5796
|
// a session's native discovery is read once a pass, as once a round (readNative)
|
|
4706
5797
|
discoveries.clear();
|
|
4707
5798
|
// the fleet reading settles a launch that never bound a pane; one unread leaves that close waiting, as a round's does
|
|
@@ -4718,6 +5809,10 @@ export async function closeEnded({ root, slugs, settings }) {
|
|
|
4718
5809
|
board = openBoard(root, slug);
|
|
4719
5810
|
board.label = boardLabel(root, slug);
|
|
4720
5811
|
setActor(boardAddress(board.label));
|
|
5812
|
+
const expiry = tx(board, db => { const effects = []; const count = settleExpiringFlags(db, board, entry, effects); return { effects, count }; });
|
|
5813
|
+
queueEffects(board, expiry.effects);
|
|
5814
|
+
report.resumed = expiry.count;
|
|
5815
|
+
report.next_pause_at = nextFlagExpiry(board.db, entry);
|
|
4721
5816
|
await closeRequested(board, report, { ttl: settings.ttl, fleet });
|
|
4722
5817
|
} catch (error) { report.error = error.message; }
|
|
4723
5818
|
finally { board?.close(); }
|
|
@@ -4750,21 +5845,35 @@ export async function closeEnded({ root, slugs, settings }) {
|
|
|
4750
5845
|
// nothing settles is not. It is the round's own: a card verb this process answers meanwhile says nothing of it.
|
|
4751
5846
|
const roundProgress = new AsyncLocalStorage();
|
|
4752
5847
|
const roundStep = (step) => { try { roundProgress.getStore()?.(step); } catch { /* the round goes on unsaid */ } };
|
|
5848
|
+
const phaseClock = (report, prefix) => {
|
|
5849
|
+
let mark = performance.now();
|
|
5850
|
+
return (name) => {
|
|
5851
|
+
const ended = performance.now(), ms = ended - mark;
|
|
5852
|
+
report.ms[name] = Math.round(ms); mark = ended;
|
|
5853
|
+
noteStep(`${prefix}: ${name}`, ms);
|
|
5854
|
+
roundStep(`${report.board ?? 'home'} ${name}`);
|
|
5855
|
+
};
|
|
5856
|
+
};
|
|
4753
5857
|
export function tick({ progress = null, ...options }) {
|
|
4754
5858
|
return roundProgress.run(progress, () => boardRound(options));
|
|
4755
5859
|
}
|
|
4756
5860
|
async function boardRound({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
|
|
5861
|
+
return measureWork("pass", "board.round", async () => {
|
|
4757
5862
|
fleetRoot = root;
|
|
4758
5863
|
discoveries.clear();
|
|
5864
|
+
const preparation = { ms: {} }, preparedPhase = phaseClock(preparation, 'dispatch home');
|
|
4759
5865
|
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
4760
5866
|
const deployment = deploymentReceipt ? await workflowRevision(root) : null;
|
|
4761
5867
|
const loaded = await loadWorkflow(root);
|
|
4762
5868
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
5869
|
+
preparedPhase('workflow');
|
|
4763
5870
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
const
|
|
4767
|
-
|
|
5871
|
+
// An unread fleet is one of the round's inputs, as the delivery pass reads it: what needs it waits, the rest runs
|
|
5872
|
+
// (ADR-0247), and its stretch is told once with its reason (surfaceStaleRecords' whole-outage path).
|
|
5873
|
+
const fleet = needFleet
|
|
5874
|
+
? await untilParked(observeFleet(), () => null).catch((error) => ({ ok: false, alive: new Set(), online: new Set(), unknown: new Set(), sessions: [], error: error.message, targetsUnread: error.fleetTargetsUnread === true }))
|
|
5875
|
+
: { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
5876
|
+
preparedPhase('fleet');
|
|
4768
5877
|
// A park (stopDispatching) ends the round at its next step, not at its end: the successor's first round does the rest,
|
|
4769
5878
|
// and every act re-reads its basis in its own transaction, so a round cut between steps leaves nothing half done. A
|
|
4770
5879
|
// round that only read has nothing to keep; one that moved cards keeps the effects those moves asked for (queueEffects
|
|
@@ -4772,21 +5881,24 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
|
|
|
4772
5881
|
// owner's 8 s wait for 'parked', and the handover rolled back (VO-3578, pin V, 21:53Z).
|
|
4773
5882
|
if (stopping) return cutRound([]);
|
|
4774
5883
|
await readMemoryPressure();
|
|
5884
|
+
preparedPhase('memory');
|
|
4775
5885
|
const boards = slugs ?? listBoards(root);
|
|
4776
5886
|
// what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so): every
|
|
4777
5887
|
// board, including one whose dispatching is off, whose live workers still hold their places
|
|
4778
5888
|
const held = dispatch ? await homeHoldings(root, listBoards(root), lanes, fleet, entry) : [];
|
|
5889
|
+
preparedPhase('holdings');
|
|
4779
5890
|
if (stopping) return cutRound([]);
|
|
4780
5891
|
const out = [];
|
|
4781
5892
|
for (const slug of boards) {
|
|
4782
5893
|
if (stopping) break;
|
|
4783
|
-
const report = { board: slug, ms: {
|
|
5894
|
+
const report = { board: slug, ms: { ...preparation.ms }, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
|
|
4784
5895
|
const path = boardPath(root, slug);
|
|
4785
5896
|
const marker = `${path}.corrupt-seen`;
|
|
4786
5897
|
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
|
|
4787
5898
|
// a copy of a home dispatches nothing: its runs, sessions and managers are the original's
|
|
4788
5899
|
if (boardCopyOf({ root })) { report.error = `skipped: ${copyRefusal({ root }, 'starts, supervises, adopts and mails nothing').message}`; out.push(report); continue; }
|
|
4789
5900
|
let board;
|
|
5901
|
+
const phase = phaseClock(report, `dispatch ${slug}`);
|
|
4790
5902
|
try {
|
|
4791
5903
|
board = openBoard(root, slug);
|
|
4792
5904
|
board.label = boardLabel(root, slug);
|
|
@@ -4795,9 +5907,7 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
|
|
|
4795
5907
|
try { board.publishPending?.(); } catch (error) { report.queued.push(['board document', `push pending: ${error.message}`]); }
|
|
4796
5908
|
// what the dispatcher records on its own is the board's act
|
|
4797
5909
|
setActor(boardAddress(board.label));
|
|
4798
|
-
|
|
4799
|
-
let mark = Date.now();
|
|
4800
|
-
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
5910
|
+
phase('open');
|
|
4801
5911
|
|
|
4802
5912
|
// each step after a park is skipped (above); the effects of what moved are still recorded (queueEffects)
|
|
4803
5913
|
const going = () => !stopping;
|
|
@@ -4806,28 +5916,42 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
|
|
|
4806
5916
|
const effects = [];
|
|
4807
5917
|
if (going()) await completeAdoptions(board, fleet, settings, report); if (going()) await reconnectContinuedSessions(board, fleet, entry, report); if (going()) await reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); if (going()) await recordBounces(board, fleet, entry, report); if (going()) await supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); if (going()) await closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
|
|
4808
5918
|
report.unsettled = [];
|
|
5919
|
+
const startAt = Date.now();
|
|
4809
5920
|
report.promoted = recomputeReady(board, effects, report.unsettled);
|
|
5921
|
+
report.next_pause_at = nextFlagExpiry(board.db, entry);
|
|
4810
5922
|
tx(board,db=>auditEndedRuns(db,board,entry));
|
|
4811
|
-
|
|
5923
|
+
phase('start_settle');
|
|
5924
|
+
if (dispatch && going()) await activatePullRequestReviews(board, entry, report, effects, { only, fleet, held, lanes });
|
|
5925
|
+
phase('start_reviews');
|
|
4812
5926
|
if (dispatch && going()) await startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
|
|
4813
|
-
phase('
|
|
5927
|
+
phase('start_cards');
|
|
5928
|
+
report.ms.start = Date.now() - startAt;
|
|
5929
|
+
const idleAt = Date.now();
|
|
4814
5930
|
if (dispatch && !only && !report.paused && going()) await observeIdle(board, fleet, settings, report, entry, effects);
|
|
4815
|
-
|
|
5931
|
+
phase('idle_sessions');
|
|
5932
|
+
if (dispatch && !only && !report.paused && going()) await retellBriefs(board, entry, report);
|
|
5933
|
+
phase('idle_briefs');
|
|
5934
|
+
if (dispatch && !only && going()) surfaceStaleRecords(board, entry, fleet, settings, report);
|
|
4816
5935
|
// what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
|
|
4817
5936
|
queueEffects(board, effects);
|
|
4818
|
-
phase('
|
|
4819
|
-
|
|
5937
|
+
phase('idle_notices');
|
|
5938
|
+
report.ms.idle = Date.now() - idleAt;
|
|
5939
|
+
if (going()) await ensureSubscriptions(board, entry);
|
|
5940
|
+
phase('subscriptions');
|
|
4820
5941
|
fireHooks(board, settings, report);
|
|
4821
5942
|
if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
|
4822
5943
|
report.ready_waiting = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'ready' AND t.assignee IS NOT NULL AND c.subtask_of IS NULL").get().n);
|
|
4823
5944
|
// what a Hermes gateway serves around its dispatcher and this one does not: said by the caller
|
|
4824
5945
|
report.chat_subscriptions = Number(board.db.prepare("SELECT COUNT(*) AS n FROM kanban_notify_subs WHERE platform != 'supercode'").get().n);
|
|
4825
5946
|
if (existsSync(marker)) unlinkSync(marker);
|
|
5947
|
+
phase('finalize');
|
|
4826
5948
|
} catch (error) {
|
|
5949
|
+
phase('refused');
|
|
4827
5950
|
report.error = error.message;
|
|
4828
5951
|
if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
|
|
4829
5952
|
} finally {
|
|
4830
5953
|
board?.close();
|
|
5954
|
+
phase('close');
|
|
4831
5955
|
}
|
|
4832
5956
|
out.push(report);
|
|
4833
5957
|
}
|
|
@@ -4837,4 +5961,7 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
|
|
|
4837
5961
|
if (receipt && out[0]) out[0].deployment = receipt;
|
|
4838
5962
|
}
|
|
4839
5963
|
return out;
|
|
4840
|
-
|
|
5964
|
+
|
|
5965
|
+
|
|
5966
|
+
});
|
|
5967
|
+
}
|