@volter/supercode-orchestrator 0.5.226 → 0.5.227
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 +29 -18
- package/adapters/rh2.mjs +6 -5
- package/bin/connection-runner.mjs +46 -12
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +6 -1
- package/board/agent-declarations.mjs +1 -1
- package/board/args.mjs +8 -1
- package/board/call.mjs +2 -2
- package/board/ci.mjs +158 -54
- package/board/cli.mjs +175 -53
- package/board/custody.mjs +5 -1
- package/board/dispatch.mjs +969 -256
- package/board/doors.mjs +7 -2
- package/board/engine.mjs +264 -48
- package/board/event-stream.mjs +3 -7
- package/board/files.mjs +4 -1
- package/board/fleet-listeners.mjs +3 -1
- package/board/fleet-observer.mjs +3 -2
- package/board/generation-owner.mjs +40 -10
- package/board/generation.mjs +17 -4
- package/board/machine-fleet.mjs +33 -9
- package/board/notices.mjs +10 -2
- package/board/published.mjs +107 -2
- package/board/readable-id.mjs +25 -2
- package/board/runtime-door.mjs +2 -2
- package/board/session-work.mjs +15 -1
- package/board/storage-contract.mjs +41 -3
- package/board/store.mjs +49 -7
- package/board/tools.mjs +1 -1
- package/board/workflow.mjs +197 -70
- package/board/workspace.mjs +99 -13
- package/board/ztrack.mjs +42 -14
- package/connection.mjs +252 -32
- package/index.d.ts +5 -1
- package/installed-apps.mjs +11 -5
- package/loop.mjs +6 -0
- package/mcp/hermes-kanban-tools.json +10 -1
- package/package.json +6 -6
- package/reducer.mjs +31 -2
package/board/dispatch.mjs
CHANGED
|
@@ -17,26 +17,28 @@
|
|
|
17
17
|
// `workflow dispatch`) is harmless: every act of a round is a compare-and-set claim, a transaction that re-reads what it
|
|
18
18
|
// acts on and does nothing when another round already acted (see `tick`). Every side effect follows a committed claim, so
|
|
19
19
|
// a crash between the two steps is answered by the next round.
|
|
20
|
+
import { GENERATION_STOPPED } from './generation.mjs';
|
|
20
21
|
import { machineCall } from './doors.mjs';
|
|
21
|
-
import { detachedCard, detachedSession, personCustody, sessionOperation, takeSessionCustody } from './custody.mjs';
|
|
22
|
+
import { detachedCard, detachedSession, personCustody, personOf, sessionOperation, takeSessionCustody } from './custody.mjs';
|
|
22
23
|
import { processSession } from './caller-session.mjs';
|
|
23
24
|
import { callerOf, current, isCallersCall, ownCall } from './call.mjs';
|
|
24
25
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
25
26
|
import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
26
27
|
import { readMachineFleet } from './machine-fleet.mjs';
|
|
27
28
|
import { homeFleetMachines } from './fleet-targets.mjs';
|
|
28
|
-
import { delegatedWork, idleInterval, questionText } from './session-work.mjs';
|
|
29
|
+
import { delegatedWork, idleInterval, questionText, resumedWatch } from './session-work.mjs';
|
|
29
30
|
import { localSessionDelegations } from '../agents.mjs';
|
|
30
31
|
import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
|
|
31
32
|
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
32
33
|
let fleetRoot = null;
|
|
33
|
-
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
34
|
+
import { inspectCompletionEvidence, returnedReview, noCodeChange, openReviewPullRequests, reviewDue, settledReviewDue, prReviewedHead, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
34
35
|
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
35
36
|
import { ACTIVE_RUN_IDS, latestEvent, readRunPages } from './queries.mjs';
|
|
36
37
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
37
38
|
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
38
39
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
39
40
|
import { materializeAgentHome } from '../agent-home.mjs';
|
|
41
|
+
import { installedAgentRecords } from '../installed-apps.mjs';
|
|
40
42
|
import { memorySnapshot } from '../hermes-cli.mjs';
|
|
41
43
|
import { hermesModel, codexPaneRoute } from '../worker-route.mjs';
|
|
42
44
|
import { refreshVault } from '../orchestration.mjs';
|
|
@@ -46,22 +48,23 @@ import { declareWorkerHome } from '../activation.mjs';
|
|
|
46
48
|
import { MODEL_ARGUMENTS, NAME_ARGUMENTS } from '../activation.mjs';
|
|
47
49
|
import { createRequire } from 'node:module';
|
|
48
50
|
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
49
|
-
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
51
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
50
52
|
import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
51
53
|
import { basename, dirname, join } from 'node:path';
|
|
52
54
|
import { createHash, randomUUID } from 'node:crypto';
|
|
53
55
|
import { fileURLToPath } from 'node:url';
|
|
56
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
54
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';
|
|
55
|
-
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
56
|
-
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.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';
|
|
57
60
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
58
61
|
import { evaluate, holds, render } from './expr.mjs';
|
|
59
|
-
import { cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
62
|
+
import { cardName, cardNumber, identifyNotice, noticeName, numbered } from './notices.mjs';
|
|
60
63
|
import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
|
|
61
64
|
import { pendingRequestMail } from './request-mail.mjs';
|
|
62
65
|
import {
|
|
63
|
-
cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
|
|
64
|
-
recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions,
|
|
66
|
+
cardContext, cardLabel, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
|
|
67
|
+
recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions, mentioned,
|
|
65
68
|
} from './engine.mjs';
|
|
66
69
|
|
|
67
70
|
const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
|
|
@@ -158,7 +161,7 @@ export function callerSession() {
|
|
|
158
161
|
export async function observeFleet(root = fleetRoot) {
|
|
159
162
|
const machines = await homeFleetMachines(root, normalMachine);
|
|
160
163
|
const fleet = await readMachineFleet(machines, localMachine());
|
|
161
|
-
presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), offline: fleet.offline ?? new Set(), unknown: fleet.unknown ?? new Set(), errors: fleet.errors ?? new Map() };
|
|
164
|
+
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() };
|
|
162
165
|
notePresence(presenceReading);
|
|
163
166
|
return fleet;
|
|
164
167
|
}
|
|
@@ -422,10 +425,23 @@ const offlineMachine = (machine) => {
|
|
|
422
425
|
const m = normalMachine(machine ?? localMachine());
|
|
423
426
|
return m !== localMachine() && presenceReading?.known === true && presenceReading.offline.has(m);
|
|
424
427
|
};
|
|
428
|
+
// The doors a round only reads through. A park (stopDispatching) ends each one still waiting, and answers it as unread
|
|
429
|
+
// at once afterwards: every reader takes an answer that is not 0 as unknown, read next round, as it takes an offline
|
|
430
|
+
// machine's. A round's supervision reads every running card's discovery one after another (up to NATIVE_DISCOVERY_MS
|
|
431
|
+
// 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
|
+
// 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
|
+
// 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',
|
|
435
|
+
'harness.v1.panes.presence', 'harness.v1.sessions.activity_under', 'harness.v1.sessions.discover', 'harness.v1.sessions.load']);
|
|
436
|
+
const refusal = (code, message) => ({ code: 1, stdout: '', stderr: JSON.stringify({ code, message }) });
|
|
425
437
|
/** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
|
|
426
|
-
const doorOn = async (machine, method, params, options) =>
|
|
427
|
-
|
|
428
|
-
|
|
438
|
+
const doorOn = async (machine, method, params, options) => {
|
|
439
|
+
if (offlineMachine(machine)) return refusal('machine_offline', `machine ${normalMachine(machine)} is unlinked in the current fleet record: ${presenceReading.errors.get(normalMachine(machine)) ?? 'unlinked from the team'}`);
|
|
440
|
+
const reads = READ_DOORS.has(method);
|
|
441
|
+
if (reads && stopping) return refusal('board_parked', parkedError().message);
|
|
442
|
+
try { return await runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, reads ? { ...options, signal: park.signal } : options); }
|
|
443
|
+
finally { roundStep(`${method} on ${normalMachine(machine ?? localMachine())}`); }
|
|
444
|
+
};
|
|
429
445
|
const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
|
|
430
446
|
|
|
431
447
|
// ------------------------------------------------------------------ supervise
|
|
@@ -516,7 +532,39 @@ async function recordBounces(board, fleet, entry, report) {
|
|
|
516
532
|
}
|
|
517
533
|
}
|
|
518
534
|
|
|
535
|
+
/**
|
|
536
|
+
* An unended run is its card's current run, or it is ended. Every path that ends a run (end_run, endRun, a re-claim,
|
|
537
|
+
* the supervision below) reaches it through `tasks.current_run_id`, so a run no card holds as current is one nothing
|
|
538
|
+
* supervises and nothing will end: it reads `running` for ever, keeps its session counted live (sessionEnded) and its
|
|
539
|
+
* pane protected from closing (recordInputClose). Each such run is ended here as reclaimed, with its card's status at
|
|
540
|
+
* the time, whether its card is done, cancelled or still open, and whether it recorded a session or none. Its session
|
|
541
|
+
* is not closed here, and none is guaranteed gone: its card's current run may carry it, or its pane may still be alive
|
|
542
|
+
* with no owner (a live card a rebuild from the documents deleted keeps its runs, ztrack.mjs `plan.remove`). Where the
|
|
543
|
+
* workflow monitors runs and the retired run is its open card's latest, reconnectContinuedSessions finds that pane
|
|
544
|
+
* still running and reopens the run as the card's current run, under supervision again; any other such pane is left
|
|
545
|
+
* as it is.
|
|
546
|
+
*/
|
|
547
|
+
const UNHELD_RUNS = `SELECT r.id, r.task_id, r.metadata, t.status AS card_status, c.stage FROM task_runs r
|
|
548
|
+
LEFT JOIN tasks t ON t.id = r.task_id LEFT JOIN supercode_cards c ON c.task_id = r.task_id
|
|
549
|
+
WHERE r.ended_at IS NULL AND NOT EXISTS (SELECT 1 FROM tasks h WHERE h.current_run_id = r.id)`;
|
|
550
|
+
function retireUnheldRuns(board, report) {
|
|
551
|
+
// read first: a round with nothing to retire opens no write; the write re-reads what it ends
|
|
552
|
+
if (!view(board, (db) => db.prepare(`${UNHELD_RUNS} LIMIT 1`).get())) return;
|
|
553
|
+
tx(board, (db) => {
|
|
554
|
+
for (const row of db.prepare(UNHELD_RUNS).all()) {
|
|
555
|
+
const session = json(row.metadata, {})?.supercode ?? null;
|
|
556
|
+
const status = row.stage ?? row.card_status ?? null;
|
|
557
|
+
const reason = `run ${row.id} is not the current run of ${row.task_id}${status ? ` (${status})` : ''}; nothing supervises it`;
|
|
558
|
+
db.prepare(`UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, ?), ended_at = ?,
|
|
559
|
+
claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL`).run(reason, now(), row.id);
|
|
560
|
+
event(db, row.task_id, 'run_retired', { reason, card_status: status, session_id: session?.session_id ?? null, pane: session?.pane ?? null, machine: session?.machine ?? null }, row.id);
|
|
561
|
+
(report.retired ??= []).push([row.task_id, row.id]);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
519
566
|
async function supervise(board, fleet, settings, report, entry, effects) {
|
|
567
|
+
retireUnheldRuns(board, report);
|
|
520
568
|
const rows = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
521
569
|
// Published native observations are the only liveness input. Missing data remains unknown. A launch the
|
|
522
570
|
// observations show stopped is asked, through its machine's door, how it ended.
|
|
@@ -883,7 +931,9 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
883
931
|
// each run's session read before it is counted live or not (unknown stays unknown)
|
|
884
932
|
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);
|
|
885
933
|
const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
|
|
886
|
-
|
|
934
|
+
// a session on a machine whose record is not current is not known gone: it keeps its place in the caps
|
|
935
|
+
out.push(...await holdings(b, { lanes, fleetOk: fleet.ok,
|
|
936
|
+
alive: (s) => sessionAlive(fleet, s) ?? (s?.surface !== 'headless' && s?.machine && fleet.unknown?.has(normalMachine(s.machine)) ? true : null), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
|
|
887
937
|
runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
|
|
888
938
|
}
|
|
889
939
|
finally { b.close(); }
|
|
@@ -974,6 +1024,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
974
1024
|
if (task.subtask_of || task.current_run_id) continue;
|
|
975
1025
|
const state = scopeFor(task).card;
|
|
976
1026
|
if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
|
|
1027
|
+
// A person's card is a dependency on that person, never work for a session: the board starts none for it, whatever
|
|
1028
|
+
// its workflow's start would allow, and it closes when that person completes it (person-done).
|
|
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; }
|
|
977
1030
|
if (state.status === 'running' && completedWork(board.db, task.id)) {
|
|
978
1031
|
waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
|
|
979
1032
|
}
|
|
@@ -1134,9 +1187,8 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1134
1187
|
const surface = lane.surface === 'pane' ? 'pane' : 'headless';
|
|
1135
1188
|
const scope = view(board, (db) => scopeOf(db, board, task, { params: entry.params }));
|
|
1136
1189
|
const where = whereOf(board, task);
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
let prompt = render(spec.prompt, { ...scope, where, context: view(board, (db) => cardContext(db, id, { nativeHolds: Boolean(entry.workflow.flags?.paused) })), arc_context: arcContext, doors: doors(entry.workflow, status, spec.role, id, where, surface) });
|
|
1190
|
+
const told = view(board, (db) => roleBrief(db, board, entry, task, status, surface));
|
|
1191
|
+
let prompt = told.prompt;
|
|
1140
1192
|
// the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
|
|
1141
1193
|
// is a template, so a step can ask for isolation by naming a new one
|
|
1142
1194
|
const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
|
|
@@ -1166,13 +1218,26 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1166
1218
|
throw Object.assign(new Error(refused.why), { launchPending: true });
|
|
1167
1219
|
}
|
|
1168
1220
|
}
|
|
1221
|
+
// A machine whose record is not current lists none of its sessions or panes: nothing opened there could be checked
|
|
1222
|
+
// against what already runs (a continuation, a cap's holdings). An explicit start or a deferred one waits for its
|
|
1223
|
+
// reading, as a round's launcherCheck does; its clock moves with the wait (reconcileLaunches).
|
|
1224
|
+
const host = lane.surface === 'pane' ? executionHost(lane, task.machine, localMachine(), normalMachine) : null;
|
|
1225
|
+
if (host && fleet?.unknown?.has(host)) {
|
|
1226
|
+
const why = `machine ${host}'s fleet record is not current: ${fleet.errors?.get(host) ?? 'no current record'}; the launch waits for it`;
|
|
1227
|
+
runTx(board, id, runId, db => {
|
|
1228
|
+
const previous = currentRun(db, id)?.metadata?.supercode;
|
|
1229
|
+
setRunSession(db, runId, { status, role: spec.role, slot, launched_at: now(), launch_deferred: true, launch_deferred_error: why });
|
|
1230
|
+
if (previous?.launch_deferred_error !== why) event(db, id, 'launch_deferred', { reason: why }, runId);
|
|
1231
|
+
});
|
|
1232
|
+
throw Object.assign(new Error(why), { launchDeferred: true, launchPending: true });
|
|
1233
|
+
}
|
|
1169
1234
|
if (spec.role === 'reviewer') {
|
|
1170
1235
|
const worker = view(board, (db) => lastSession(db, id, 'worker'));
|
|
1171
1236
|
const reviewer = view(board, (db) => lastSession(db, id, slot));
|
|
1172
1237
|
if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
|
|
1173
1238
|
}
|
|
1174
1239
|
// the model it runs is recorded with the session, for caps that count by model
|
|
1175
|
-
const meta = { status, role: spec.role, slot, model: task.model_override ?? lane.model ?? null, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
|
|
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) } : {}) };
|
|
1176
1241
|
try {
|
|
1177
1242
|
// Teams is never on a launch's path: the card's agent is its identity minted on this host under its retained intent,
|
|
1178
1243
|
// and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
|
|
@@ -1237,15 +1302,86 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1237
1302
|
* line stays one line and far under that size whatever the card holds; the session on the board's machine reads the
|
|
1238
1303
|
* file, one elsewhere prints it through the board's door.
|
|
1239
1304
|
*/
|
|
1240
|
-
export function briefByReference(board, task, runId, role, where, prompt) {
|
|
1305
|
+
export function briefByReference(board, task, runId, role, where, prompt, how = 'started') {
|
|
1241
1306
|
const file = briefPath(board.root, board.slug, task.id, runId);
|
|
1242
1307
|
mkdirSync(dirname(file), { recursive: true });
|
|
1243
1308
|
writeFileSync(file, `${prompt}\n`, { mode: 0o600 });
|
|
1309
|
+
const card = cardLabel(boardKey(board.root, board.slug), cardNumber(board.db, task.id), task.id, readableIdOf(board, task.id));
|
|
1310
|
+
const opening = how === 'attached' ? `The ${board.label} board attached you to run ${runId} of card ${card} as its ${role}`
|
|
1311
|
+
: how === 'changed' ? `The ${board.label} board's brief for the ${role} of card ${card} changed (run ${runId})`
|
|
1312
|
+
: `The ${board.label} board started run ${runId} of card ${card} and you are its ${role}`;
|
|
1313
|
+
return `${opening}. Your instructions from the board are the brief ${briefFrom(board, task, file, where)}, then carry it out as your own task without waiting for another message.`;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* A card role's brief: the workflow's prompt for the role the card's status runs (the home's prompts/<role>.md), rendered
|
|
1318
|
+
* over the card, its arc and the doors that role has. It is the one text every path that puts a session on a card in a
|
|
1319
|
+
* role gives that session: a launch (a fresh start, a resumed or replaced session, a reviewer's), an adoption, and a
|
|
1320
|
+
* telling again when the home's prompt for the role changes (retellBriefs). `digest` names the prompt it came from.
|
|
1321
|
+
*/
|
|
1322
|
+
export function roleBrief(db, board, entry, task, status, surface = 'pane') {
|
|
1323
|
+
const spec = runSpec(entry, status, task);
|
|
1324
|
+
if (!spec) return null;
|
|
1325
|
+
const where = whereOf(board, task);
|
|
1326
|
+
const nativeHolds = Boolean(entry.workflow.flags?.paused);
|
|
1327
|
+
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
1328
|
+
// 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 }),
|
|
1330
|
+
arc_context: task.subtask_of ? cardContext(db, task.subtask_of, { nativeHolds }) : '', doors: doors(entry.workflow, status, spec.role, task.id, where, surface) });
|
|
1331
|
+
return { spec, where, prompt, digest: promptDigest(spec) };
|
|
1332
|
+
}
|
|
1333
|
+
const promptDigest = (spec) => spec?.prompt ? createHash('sha256').update(String(spec.prompt)).digest('hex').slice(0, 16) : null;
|
|
1334
|
+
|
|
1335
|
+
/** File a role's brief (roleBrief) to the session at `address` on run `runId`, by reference, as a session notice in the
|
|
1336
|
+
* caller's transaction, and record which prompt it was. */
|
|
1337
|
+
function queueBrief(db, board, task, runId, address, told, how) {
|
|
1338
|
+
const role = told.spec.role ?? 'implementer';
|
|
1339
|
+
const text = briefByReference(board, task, runId, role, told.where, told.prompt, how);
|
|
1340
|
+
const source = event(db, task.id, 'brief_told', { address, role, how, prompt_digest: told.digest }, runId);
|
|
1341
|
+
const effect = { kind: 'session_notice', id: task.id, runId, address, text, sourceEvent: source, verb: how === 'changed' ? 'brief_changed' : 'brief',
|
|
1342
|
+
messageKey: `board-brief-${task.id}-${runId}-${told.digest}-${how}-${createHash('sha256').update(address).digest('hex').slice(0, 12)}` };
|
|
1343
|
+
db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
1344
|
+
setRunSession(db, runId, { prompt_digest: told.digest });
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Each session working a card is told its role's brief again, once, when the home's prompt for that role is not the one
|
|
1349
|
+
* 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
|
+
* again on every change. A session being launched, closed, released or in a person's hands is told nothing here.
|
|
1351
|
+
*/
|
|
1352
|
+
function retellBriefs(board, entry, report) {
|
|
1353
|
+
// each card in its own write: a brief that cannot be told (its prompt unreadable, its card changed) is said in the
|
|
1354
|
+
// round's report and stops no other card's
|
|
1355
|
+
for (const { id } of view(board, (db) => db.prepare('SELECT id FROM tasks WHERE current_run_id IS NOT NULL').all())) {
|
|
1356
|
+
try {
|
|
1357
|
+
tx(board, (db) => {
|
|
1358
|
+
const run = currentRun(db, id), session = run?.metadata?.supercode, address = addressOf(session);
|
|
1359
|
+
if (!run || run.ended_at != null || !address || session.surface === 'headless' || session.launch_pending || session.launch_deferred
|
|
1360
|
+
|| session.close_requested || session.close_confirmed_at || session.released || detachedSession(db, session)) return;
|
|
1361
|
+
const task = getTask(db, id), status = statusOf(db, task);
|
|
1362
|
+
if (promptDigest(runSpec(entry, status, task)) === (session.prompt_digest ?? null)) return;
|
|
1363
|
+
const told = roleBrief(db, board, entry, task, status, 'pane');
|
|
1364
|
+
if (!told?.digest) return;
|
|
1365
|
+
queueBrief(db, board, task, run.id, address, told, 'changed');
|
|
1366
|
+
(report.briefs_retold ??= []).push([id, run.id, told.digest]);
|
|
1367
|
+
});
|
|
1368
|
+
} catch (error) { (report.failed ??= []).push([id, `brief: ${String(error.message).slice(-200)}`]); }
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
/** Where a run's session reads its brief: the file on the board's machine, the board's door on any other. */
|
|
1372
|
+
function briefFrom(board, task, file, where) {
|
|
1244
1373
|
const local = !task.machine || normalMachine(task.machine) === localMachine();
|
|
1245
|
-
|
|
1374
|
+
return local ? `in the file ${file}: read the whole file now`
|
|
1246
1375
|
: `kept by the board: print it with \`supercode workflow brief ${task.id} ${where}\` (into a file outside your repository when it is long) and read all of it now`;
|
|
1247
|
-
|
|
1248
|
-
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* The run's pending brief, said again in an idle reminder: a session that stopped before its first turn (its first input
|
|
1379
|
+
* undelivered, its turn interrupted) still has the whole of its instructions in the brief, and a reminder that only
|
|
1380
|
+
* says "continue" names nothing it was told. Null when the run kept no brief (a headless run's prompt is its argv).
|
|
1381
|
+
*/
|
|
1382
|
+
function pendingBrief(board, task, runId, where) {
|
|
1383
|
+
const file = briefPath(board.root, board.slug, task.id, runId);
|
|
1384
|
+
return existsSync(file) ? `Run ${runId}'s instructions from the board are its brief ${briefFrom(board, task, file, where)}, and carry it out.` : null;
|
|
1249
1385
|
}
|
|
1250
1386
|
|
|
1251
1387
|
/** Start one card now by a person's word (a triage card's session, a manager's start): the workflow's start event. */
|
|
@@ -1258,6 +1394,8 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
1258
1394
|
const before = getTaskNow(board, id), checked = await checkWorkspace(board, before);
|
|
1259
1395
|
const moved = tx(board, (db) => {
|
|
1260
1396
|
if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
|
|
1397
|
+
const person = personOf(getTask(db, id)?.assignee);
|
|
1398
|
+
if (person) throw new Error(`${id} is assigned to ${person}: a person's card runs no session; it closes when that person completes it`);
|
|
1261
1399
|
assertWorkspaceOwner(db, before, checked);
|
|
1262
1400
|
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1263
1401
|
if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
|
|
@@ -1332,8 +1470,11 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1332
1470
|
const machine = session.machine ?? localMachine();
|
|
1333
1471
|
const recordOf = (rows, id, harness) => rows.find((row) => row.locator?.session_id === id && row.locator?.harness === harness);
|
|
1334
1472
|
let activity, rows = null;
|
|
1473
|
+
// A Codex pane may run under its own Codex home (a pane route's CODEX_HOME, recorded on the run as codex_home), which
|
|
1474
|
+
// the machine's default sources do not hold: every read of its session here names that home, as readNative does.
|
|
1475
|
+
const codexHome = (session.harness ?? 'claude-code') === 'codex' ? session.codex_home ?? null : null;
|
|
1335
1476
|
if (pane?.pid) {
|
|
1336
|
-
const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid] }, { timeout: NATIVE_DISCOVERY_MS });
|
|
1477
|
+
const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid], ...(codexHome ? { homes: { codex: join(codexHome, 'sessions') } } : {}) }, { timeout: NATIVE_DISCOVERY_MS });
|
|
1337
1478
|
const page = r.code === 0 ? lastJson(r.stdout) : null;
|
|
1338
1479
|
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)})` };
|
|
1339
1480
|
activity = page.activities.find((item) => item.pid === pane.pid)?.activity ?? null;
|
|
@@ -1349,7 +1490,7 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1349
1490
|
if (command && !HARNESS_COMMANDS.has(command)) return null;
|
|
1350
1491
|
const unfound = `pane ${session.pane} runs ${command || 'a program it does not name'}, whose session could not be read`;
|
|
1351
1492
|
if (!session.session_id) return { why: unfound };
|
|
1352
|
-
try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id)).sessions; }
|
|
1493
|
+
try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, codexHome)).sessions; }
|
|
1353
1494
|
catch (error) { return { why: `${unfound} (${String(error.message).slice(0, 160)})` }; }
|
|
1354
1495
|
activity = recordOf(rows, session.session_id, session.harness ?? 'claude-code')?.activity ?? null;
|
|
1355
1496
|
// a row that shows no live owner beside a pane that runs a harness places nothing: another session may run there
|
|
@@ -1359,7 +1500,7 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1359
1500
|
return { why: `pane ${session.pane} names no process to read` };
|
|
1360
1501
|
} else {
|
|
1361
1502
|
if (!session.session_id) return { why: 'its session is not known yet' };
|
|
1362
|
-
try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id)).sessions; }
|
|
1503
|
+
try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, codexHome)).sessions; }
|
|
1363
1504
|
catch (error) { return { why: `its session could not be read just now (${String(error.message).slice(0, 160)})` }; }
|
|
1364
1505
|
activity = nativeReading(rows, session).row?.activity ?? null;
|
|
1365
1506
|
if (!activity) return { why: `its session ${addressOf(session)} is not in its machine's discovery` };
|
|
@@ -1383,7 +1524,7 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1383
1524
|
if (blocked) return { stopped: blocked };
|
|
1384
1525
|
const sessionId = activity.session_id ?? session.session_id, harness = activity.harness ?? session.harness ?? 'claude-code';
|
|
1385
1526
|
if (!sessionId || !quiet) return { why: unknown };
|
|
1386
|
-
if (!rows) try { rows = (await discoverNative(machine, harness, sessionId)).sessions; }
|
|
1527
|
+
if (!rows) try { rows = (await discoverNative(machine, harness, sessionId, codexHome)).sessions; }
|
|
1387
1528
|
catch (error) { return { why: `${unknown}, and its record could not be read just now (${String(error.message).slice(0, 160)})` }; }
|
|
1388
1529
|
const record = recordOf(rows, sessionId, harness);
|
|
1389
1530
|
if (TURN_IN_PROGRESS.has(record?.activity?.turn)) return inTurn(record.activity.turn);
|
|
@@ -1469,7 +1610,17 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1469
1610
|
if (current) retireSupersededRuns(db, current.task_id, row.id, json(current.metadata, {})?.supercode);
|
|
1470
1611
|
}
|
|
1471
1612
|
});
|
|
1613
|
+
// 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
|
+
// until it is closed (a pane whose program failed is kept, marked dead, for its last screen), so it is closed as any.
|
|
1472
1615
|
const panes = new Map();
|
|
1616
|
+
const paneListOn = async (machine) => {
|
|
1617
|
+
if (!panes.has(machine)) {
|
|
1618
|
+
const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
|
|
1619
|
+
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
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)}` });
|
|
1621
|
+
}
|
|
1622
|
+
return panes.get(machine);
|
|
1623
|
+
};
|
|
1473
1624
|
for (const row of rows) {
|
|
1474
1625
|
await yieldToOwner();
|
|
1475
1626
|
let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
@@ -1533,6 +1684,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1533
1684
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); })) continue;
|
|
1534
1685
|
finalFailure = false;
|
|
1535
1686
|
} else if (observed.present !== false) continue;
|
|
1687
|
+
// presence answers for a pane whose program runs: one its machine still lists dead is not gone, and its refusal stands
|
|
1688
|
+
else if ((await paneListOn(session.machine ?? localMachine())).dead?.has(session.pane)) continue;
|
|
1536
1689
|
}
|
|
1537
1690
|
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
|
|
1538
1691
|
// Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
|
|
@@ -1566,6 +1719,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1566
1719
|
return true;
|
|
1567
1720
|
});
|
|
1568
1721
|
if (!claimed) continue;
|
|
1722
|
+
noteClaim(board, 'runs', row.id);
|
|
1569
1723
|
try {
|
|
1570
1724
|
const machine = session.machine ?? localMachine();
|
|
1571
1725
|
if (session.surface === 'native' && !session.pane) {
|
|
@@ -1578,17 +1732,17 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1578
1732
|
await stopSession(board, session);
|
|
1579
1733
|
}
|
|
1580
1734
|
} else if (!finalFailure) {
|
|
1581
|
-
|
|
1582
|
-
const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
|
|
1583
|
-
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1584
|
-
panes.set(machine, Array.isArray(list) ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null }])) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
|
|
1585
|
-
}
|
|
1586
|
-
const observed = panes.get(machine);
|
|
1735
|
+
const observed = await paneListOn(machine);
|
|
1587
1736
|
if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
|
|
1588
|
-
|
|
1737
|
+
const dead = observed.dead.has(session.pane);
|
|
1738
|
+
if (dead || observed.live.has(session.pane)) {
|
|
1589
1739
|
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
|
|
1590
1740
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
1591
1741
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
1742
|
+
}
|
|
1743
|
+
// 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); }
|
|
1745
|
+
else if (observed.live.has(session.pane)) {
|
|
1592
1746
|
// An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
|
|
1593
1747
|
// already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
|
|
1594
1748
|
if (row.ended_at) {
|
|
@@ -1652,7 +1806,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1652
1806
|
}
|
|
1653
1807
|
|
|
1654
1808
|
// When this process last read each run's turn (run id → seconds). Kept in memory on purpose: a board that restarted
|
|
1655
|
-
// has
|
|
1809
|
+
// has read nothing itself, and its first reading of a run is where that run's watch resumes (resumedWatch).
|
|
1656
1810
|
const idleWatch = new Map();
|
|
1657
1811
|
/** Native idle intervals are independent of dispatch cadence and transcript writes.
|
|
1658
1812
|
* Keep bounded readings on the run so an ordinary quiet stretch can be reviewed later. */
|
|
@@ -1720,24 +1874,35 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1720
1874
|
// clock of the run moves on a round that could not see it
|
|
1721
1875
|
if (session.machine && !fleet.online.has(session.machine)) return;
|
|
1722
1876
|
if (session.surface === 'native' && sessionAlive(fleet, session) !== true) return;
|
|
1723
|
-
//
|
|
1877
|
+
// A session the machine's record publishes no activity for has its turn on its machine's discovery row, which
|
|
1878
|
+
// readBefore read: a session in no pane, and a live pane whose record names no session in it (a Codex pane on its
|
|
1879
|
+
// own Codex home, VO-3259 run 2408: supervise read its turn idle there while this read none, so its run took the
|
|
1880
|
+
// unknown-turn notice once and was never reminded or reclaimed). A pane is read so only while its record shows it.
|
|
1881
|
+
const published = fleet.sessions.find((x) => x.address === addressOf(session));
|
|
1724
1882
|
let nativeRow = null;
|
|
1725
|
-
if (session.surface === 'native') try { nativeRow = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row; } catch { /* unread: no turn */ }
|
|
1726
|
-
const live =
|
|
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,
|
|
1727
1885
|
turn_source: nativeRow.activity?.evidence?.source ?? null, turn_since_ms: nativeRow.activity?.evidence?.native_state_since_ms ?? null } : undefined);
|
|
1728
1886
|
const observedAt = now();
|
|
1729
1887
|
// Quiet time is time the board watched. A run this process has not read for a while (the board restarted, was
|
|
1730
|
-
// paused, could not read the fleet or the machine)
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1888
|
+
// paused, could not read the fleet or the machine) resumes its idle clock where resumedWatch reads it: from the
|
|
1889
|
+
// harness's own idle-since where its session reads idle with one and its machine's record was current over it,
|
|
1890
|
+
// else from this reading, so a gap the board could not see is not idleness and no reminder is spent on it
|
|
1891
|
+
// (volter-org t_9a9c2a9c: reminder 1 in the second its machine came back, the run reclaimed 45 minutes later).
|
|
1892
|
+
// The unknown-turn clock starts here.
|
|
1733
1893
|
const lastWatched = idleWatch.get(runRow.id);
|
|
1734
1894
|
if (lastWatched == null || observedAt - lastWatched > Math.max(3 * settings.interval, settings.idleQuiet / 2)) {
|
|
1735
|
-
|
|
1736
|
-
|
|
1895
|
+
const resumed = resumedWatch(session, live, runRow, fleet.linkedSince?.get(session.machine ?? localMachine()), observedAt);
|
|
1896
|
+
setRunSession(db, runRow.id, { watch_since: resumed, unknown_since: null });
|
|
1897
|
+
session = { ...session, watch_since: resumed, unknown_since: null };
|
|
1737
1898
|
}
|
|
1738
1899
|
watched = [runRow.id, observedAt];
|
|
1739
|
-
|
|
1900
|
+
// A reviewer's work is its verdict, and its verdict ends its run: nothing the card shows while the review runs is
|
|
1901
|
+
// the reviewer's progress (the implementer's commits, a card specified or a subtask closed would reset its reminders
|
|
1902
|
+
// for as long as anyone else works), and an unread checkout does not hold them.
|
|
1903
|
+
const reviewing = (session.slot ?? 'worker') === 'reviewer';
|
|
1740
1904
|
const priorProgress = session.work_progress;
|
|
1905
|
+
const read = reviewing ? { event: priorProgress?.event ?? sessionProgress(db, task).event, commit: null } : sessionProgress(db, task);
|
|
1741
1906
|
// The commit a run is measured from is the last one read. A checkout that could not be read (its machine silent,
|
|
1742
1907
|
// its daemon without the door) keeps that baseline: its first reading after is where it stands, not work done.
|
|
1743
1908
|
const progress = { event: read.event, commit: read.commit ?? priorProgress?.commit ?? null, ...(read.commit == null ? { commit_unread: read.commit_unread } : {}) };
|
|
@@ -1845,10 +2010,13 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1845
2010
|
// A native idle receipt also works without a transcript timestamp.
|
|
1846
2011
|
const interval = idleInterval(session, live, observedAt);
|
|
1847
2012
|
reading.idle_since = interval.since; reading.quiet_seconds = interval.quiet;
|
|
2013
|
+
reading.idle_since_at = new Date(interval.since * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
1848
2014
|
if (session.idle_since !== interval.since) setRunSession(db, runRow.id, { idle_since: interval.since });
|
|
1849
|
-
// The workflow declares the threshold, repeat interval, eligibility
|
|
1850
|
-
//
|
|
1851
|
-
//
|
|
2015
|
+
// The workflow declares the threshold, repeat interval, eligibility (whether
|
|
2016
|
+
// the board shows a recorded wait), audience and notice. The observer only
|
|
2017
|
+
// supplies a watched native idle interval. This path precedes
|
|
2018
|
+
// heartbeat/nudge cooldown and unread checkout handling.
|
|
2019
|
+
let stalled = false;
|
|
1852
2020
|
if (handles('session_stalled')) {
|
|
1853
2021
|
const limits = limitsOf(db, board, entry, task, session);
|
|
1854
2022
|
const first = Number(limits.idle_notice), repeat = Number(limits.idle_notice_repeat);
|
|
@@ -1862,6 +2030,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1862
2030
|
last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior) };
|
|
1863
2031
|
const result = fire(db, board, entry, id, 'session_stalled', { roles: ['platform'], payload });
|
|
1864
2032
|
staged.push(...result.effects);
|
|
2033
|
+
stalled = true;
|
|
1865
2034
|
setRunSession(db, runRow.id, { idle_notice: { native_since_ms: nativeIdle, since: interval.since, at: observedAt } });
|
|
1866
2035
|
report.notified.push([id, 'session_stalled', address]);
|
|
1867
2036
|
} catch (error) {
|
|
@@ -1869,24 +2038,41 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1869
2038
|
}
|
|
1870
2039
|
}
|
|
1871
2040
|
if (!handles('session_idle')) return;
|
|
2041
|
+
// a session told it stalled is not also reminded for a whole quiet period after that notice
|
|
2042
|
+
if (stalled || (Number(session.idle_notice?.at) > 0 && observedAt - Number(session.idle_notice.at) < settings.idleQuiet)) { retain('stall_notified'); return; }
|
|
1872
2043
|
if (reading.quiet_seconds < settings.idleQuiet) { retain('below_threshold'); return; }
|
|
1873
2044
|
if (session.goal_nudged_at && observedAt - session.goal_nudged_at < settings.idleQuiet) { retain('nudge_cooldown'); return; }
|
|
1874
2045
|
// a session whose first input bounced was never told its work: its quiet is not idleness (recordBounces told its manager)
|
|
1875
2046
|
if (session.first_input === 'bounced') { retain('first_input_bounced'); return; }
|
|
1876
2047
|
// A reminder says the card shows no progress, and the last of them ends the run for it. With the card's checkout
|
|
1877
2048
|
// unread that is not known: nothing is said or counted until it is read (the reading's `progress` says why).
|
|
1878
|
-
if (read.commit == null) { retain('progress_unread'); return; }
|
|
2049
|
+
if (read.commit == null && !reviewing) { retain('progress_unread'); return; }
|
|
1879
2050
|
try {
|
|
1880
2051
|
const where = whereOf(board, task);
|
|
1881
2052
|
// the ask names what the session may do, as its run's prompt did
|
|
1882
2053
|
const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface !== 'headless' ? 'pane' : 'headless');
|
|
1883
|
-
const
|
|
2054
|
+
const brief = pendingBrief(board, task, runRow.id, where);
|
|
2055
|
+
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
|
|
1884
2057
|
staged.push(...r.effects);
|
|
1885
2058
|
const after = currentRun(db, id);
|
|
1886
2059
|
const stored = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runRow.id)?.metadata, {})?.supercode;
|
|
1887
2060
|
const observation = retain('dispatched', { turns_after: Number(stored?.goal_turns ?? 0), nudge_queued: r.effects.some(e => e.kind === 'send_to_actor'), run_ended: after?.id !== runRow.id });
|
|
1888
2061
|
event(db, id, 'session_idle_observed', observation, runRow.id);
|
|
1889
2062
|
if (after?.id === runRow.id) setRunSession(db, runRow.id, { goal_nudged_at: observedAt });
|
|
2063
|
+
else if (reviewing && db.prepare('SELECT ended_at FROM task_runs WHERE id = ?').get(runRow.id)?.ended_at != null) {
|
|
2064
|
+
// A reviewer that stayed idle through its reminders with no verdict has its run ended and its session closed;
|
|
2065
|
+
// the card's next review run (the board's own, its card still in review) resumes that same reviewer session
|
|
2066
|
+
// with its round's brief, as every review round does. Nothing is voided: no verdict was given, and every
|
|
2067
|
+
// earlier one stands.
|
|
2068
|
+
requestSessionClose(db, id, { runId: runRow.id });
|
|
2069
|
+
const ended = db.prepare('SELECT outcome FROM task_runs WHERE id = ?').get(runRow.id)?.outcome ?? null;
|
|
2070
|
+
event(db, id, 'review_run_restarted', { run_id: runRow.id, session: address, outcome: ended, reminders: reading.turns_before, quiet_seconds: reading.quiet_seconds }, runRow.id);
|
|
2071
|
+
const card = getTask(db, id), held = Boolean(card?.blocked_by?.length || card?.paused);
|
|
2072
|
+
const to = managers[0] ?? null;
|
|
2073
|
+
if (to) event(db, id, 'board_message', { to, text: `${id}: its reviewer session ${address} stayed idle with no verdict through ${reading.turns_before} reminder(s) carrying its brief; the board ended review run ${runRow.id} (${ended ?? 'ended'}) and closed that session. ${held ? 'The card is held (its failure limit or a block), so its next review resumes that reviewer session once it is released.' : 'Its next review run resumes that reviewer session.'} No verdict was voided.` }, null);
|
|
2074
|
+
(report.review_restarted ??= []).push([id, runRow.id, address]);
|
|
2075
|
+
}
|
|
1890
2076
|
report.notified.push([id, 'session_idle', addressOf(session)]);
|
|
1891
2077
|
} catch (error) {
|
|
1892
2078
|
retain('dispatch_failed', { error: String(error.message).slice(-200) });
|
|
@@ -1923,7 +2109,7 @@ export async function sessionNotice(board, e) {
|
|
|
1923
2109
|
const address = e.address ?? addressOf(prior);
|
|
1924
2110
|
if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
1925
2111
|
const card = view(board, (db) => getTask(db, e.id));
|
|
1926
|
-
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}
|
|
2112
|
+
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));
|
|
1927
2113
|
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
|
|
1928
2114
|
tx(board, (db) => {
|
|
1929
2115
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
@@ -1946,7 +2132,7 @@ export async function sessionEffect(board, e) {
|
|
|
1946
2132
|
if (!address) throw new Error('actor session identity unavailable');
|
|
1947
2133
|
// filed in the actor's mailbox, which delivers it
|
|
1948
2134
|
const card = view(board, (db) => getTask(db, e.id));
|
|
1949
|
-
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}
|
|
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)) });
|
|
1950
2136
|
if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
|
|
1951
2137
|
return true;
|
|
1952
2138
|
}
|
|
@@ -2191,16 +2377,18 @@ async function readRunUsage(root,org,row,endedAt){
|
|
|
2191
2377
|
/** The board's agent records: each run kept in the agent store, closed cards' assignments ended, declarations and ends
|
|
2192
2378
|
* handed to this machine's daemon, and usage read from each machine's own doors. No Teams call: the daemon declares and
|
|
2193
2379
|
* ends agents and binds their sessions (D137). It runs in the delivery pass (`deliver`), never in a dispatch round. */
|
|
2194
|
-
async function synchronizeAgents(board, report) {
|
|
2380
|
+
async function synchronizeAgents(board, report, fleet = null) {
|
|
2195
2381
|
// A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
|
|
2196
2382
|
// A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
|
|
2197
2383
|
const agentRuns=[...new Map((await readRunPages(board.db, 'id,task_id,metadata,ended_at', { where: "json_extract(metadata,'$.supercode.agent_id') IS NOT NULL" }))
|
|
2198
2384
|
.map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
|
|
2385
|
+
const live=new Map();
|
|
2199
2386
|
for(const scanned of agentRuns){
|
|
2200
2387
|
await yieldToOwner();
|
|
2201
2388
|
const row=view(board,db=>db.prepare('SELECT id,task_id,metadata,ended_at FROM task_runs WHERE id=?').get(scanned.id));
|
|
2202
2389
|
if(!row)continue;
|
|
2203
2390
|
const session=json(row.metadata,{})?.supercode;
|
|
2391
|
+
if(credentialsWanted(board,row,session,fleet))live.set(credentialsKey(session.machine,session.agent_id),{task:row.task_id,run:row.id});
|
|
2204
2392
|
if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
|
|
2205
2393
|
const ref=`${session.harness}:${session.session_id}`;
|
|
2206
2394
|
// An ended run whose session the agent store already holds for another agent lost that claim when it ran: no round
|
|
@@ -2219,6 +2407,7 @@ async function synchronizeAgents(board, report) {
|
|
|
2219
2407
|
try{recordAgentRun(board.root,session.agent_org,session.agent_id,{session:ref,machine:session.machine,run:row.id});}
|
|
2220
2408
|
catch(error){report.queued.push([row.task_id,`run ${row.id}: ${error.message}`]);continue;}
|
|
2221
2409
|
}
|
|
2410
|
+
await keepAgentCredentialsCurrent(board,live,report);
|
|
2222
2411
|
// G3: an ended run's session usage, read once from its harness's own records on its machine
|
|
2223
2412
|
for(const org of new Set(agentRuns.map(row=>json(row.metadata,{})?.supercode?.agent_org).filter(Boolean))){
|
|
2224
2413
|
let read=0;
|
|
@@ -2257,6 +2446,9 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2257
2446
|
const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
|
|
2258
2447
|
const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
|
|
2259
2448
|
const lane=lanes?.[profile];if(!lane){report.queued.push([id,'launch waits for its configured lane']);continue;}
|
|
2449
|
+
// a launch bound to a machine whose record is not current was not tried either: its clock moves with the wait too
|
|
2450
|
+
const host=lane.surface==='pane'&&task.machine?executionHost(lane,task.machine,localMachine(),normalMachine):null;
|
|
2451
|
+
if(host&&fleet.unknown?.has(host)){tx(board,db=>{if(stillDeferred(db))setRunSession(db,run_id,{launched_at:now()});});report.queued.push([id,`launch waits for machine ${host}'s fleet record: ${fleet.errors?.get(host)??'no current record'}`]);continue;}
|
|
2260
2452
|
const why=launcherCheck(board.db,task,lane,fleet);if(why){report.queued.push([id,why]);continue;}
|
|
2261
2453
|
const claimed=tx(board,db=>{if(!stillDeferred(db))return false;setRunSession(db,run_id,{launch_deferred:false});return true;});
|
|
2262
2454
|
if(!claimed){report.queued.push([id,'its launch was taken by another round']);continue;}
|
|
@@ -2325,6 +2517,146 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
|
|
|
2325
2517
|
}
|
|
2326
2518
|
}
|
|
2327
2519
|
|
|
2520
|
+
// An agent's own Workplace credentials (its records in this home's apps/, installed-apps.mjs) reach each other machine
|
|
2521
|
+
// where a session of it runs (sdk/teams/workplace/agent-credentials.mjs keepAgentCredentialsHere), into a home there of
|
|
2522
|
+
// this board's own (its scope: sha256 of JSON [this machine, the real home, the slug]), so one board never keeps or
|
|
2523
|
+
// removes what another handed. They are handed over the board's operator channel before a pane opens, and again by the
|
|
2524
|
+
// delivery pass whenever they change (a renewal, a revocation) or half their lease is gone while a run of the agent is
|
|
2525
|
+
// live there; a machine where none is live any more is told to keep none.
|
|
2526
|
+
//
|
|
2527
|
+
// What the board handed each machine is kept in its own store (supercode_board_state `agent_credentials_handed`, per
|
|
2528
|
+
// machine and agent): the scope it was kept under and the home that answered, older scopes still to be emptied
|
|
2529
|
+
// (`stale`), a digest of the records without their tokens, the lease's end, the run and card it was handed for, and the
|
|
2530
|
+
// attempt that wrote it. Re-keeps and removals go to the stored scope, never one computed again (a machine name read
|
|
2531
|
+
// from the host name, a renamed machine or a home named through a link computes another); a launch whose scope differs
|
|
2532
|
+
// empties the stored one first. An entry is dropped only once the machine answered keeping none and nothing wrote it
|
|
2533
|
+
// meanwhile (compare-and-set on `attempt`), so an offline machine, a lost answer, a restarted board, a separate
|
|
2534
|
+
// `workflow deliver` or a concurrent pass is told again. Archiving or deleting a board drains its entries first
|
|
2535
|
+
// (drainAgentCredentials). And whatever the board forgets (an archived board's leftovers, a board switched off, a
|
|
2536
|
+
// store lost), each copy is a lease the receiving machine removes itself once it passes.
|
|
2537
|
+
const HANDED = 'agent_credentials_handed';
|
|
2538
|
+
const credentialsKey = (machine, agent) => `${normalMachine(machine)}|${agent}`;
|
|
2539
|
+
const credentialsDigest = (records) => createHash('sha256').update(JSON.stringify(records.map(({ token, ...rest }) => rest))).digest('hex');
|
|
2540
|
+
const realRoot = (root) => { try { return realpathSync(root); } catch { return root; } };
|
|
2541
|
+
const credentialsScope = (board) => createHash('sha256').update(JSON.stringify([localMachine(), realRoot(board.root), board.slug])).digest('hex');
|
|
2542
|
+
const handedOf = (db) => json(db.prepare('SELECT value FROM supercode_board_state WHERE key=?').get(HANDED)?.value, {}) ?? {};
|
|
2543
|
+
/** Change one entry of the handed set: `change(entry or null)` answers its next value, or null to drop it. */
|
|
2544
|
+
function setHanded(board, key, change) {
|
|
2545
|
+
tx(board, (db) => {
|
|
2546
|
+
const handed = handedOf(db), value = change(handed[key] ?? null);
|
|
2547
|
+
if (value) handed[key] = value; else delete handed[key];
|
|
2548
|
+
db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(HANDED, JSON.stringify(handed));
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
const runOpen = (board, run) => run != null && Boolean(view(board, (db) => db.prepare('SELECT 1 FROM task_runs WHERE id=? AND ended_at IS NULL').get(run)));
|
|
2552
|
+
/** Whether a run's agent wants its credentials on the run's other machine: the run is live (bound yet or not), or it
|
|
2553
|
+
* was detached to a person and its session still runs there (or the fleet cannot say it does not). */
|
|
2554
|
+
function credentialsWanted(board, row, session, fleet) {
|
|
2555
|
+
if (!session?.agent_id || !session.machine || normalMachine(session.machine) === localMachine()) return false;
|
|
2556
|
+
if (!row.ended_at) return true;
|
|
2557
|
+
if (!view(board, (db) => detachedSession(db, session) && personCustody(db, addressOf(session)))) return false;
|
|
2558
|
+
const address = addressOf(session);
|
|
2559
|
+
if (!fleet?.ok || fleet.unknown?.has(normalMachine(session.machine))) return true;
|
|
2560
|
+
return Boolean(address && (fleet.alive?.has(address) || (fleet.native?.has(address) && fleet.native.get(address).alive !== false)));
|
|
2561
|
+
}
|
|
2562
|
+
const UNREACHABLE = new Set(['machine_offline', 'REQUEST_TIMEOUT', 'unavailable', 504, '504', 'ETIMEDOUT', 'ECONNRESET']);
|
|
2563
|
+
/** One keep (records) or removal (none) in the home of `scope` on `machine`: `{ home, leaseUntil }`, or `{ why }`. A
|
|
2564
|
+
* machine `unreachable` already holds this pass is not asked again in it. */
|
|
2565
|
+
async function credentialsDoor(board, machine, scope, agent, records, unreachable) {
|
|
2566
|
+
if (unreachable?.has(normalMachine(machine))) return { why: `machine ${machine} did not answer earlier in this pass` };
|
|
2567
|
+
const r = await doorOn(machine, 'harness.v1.agent.credentials.keep', { scope, agent, records }, { timeout: 30_000, as: boardAddress(board.label) });
|
|
2568
|
+
const kept = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
2569
|
+
if (typeof kept?.home === 'string') return kept;
|
|
2570
|
+
const refusal = json(String(r.stderr ?? '').trim().split('\n').pop(), null);
|
|
2571
|
+
if (UNREACHABLE.has(refusal?.code)) unreachable?.add(normalMachine(machine));
|
|
2572
|
+
return { why: (r.stderr || r.stdout).trim().slice(-300) || 'the machine named no agent home' };
|
|
2573
|
+
}
|
|
2574
|
+
/** Empty the stale scopes of an entry; each emptied one leaves the list (and, should it be the entry's scope again,
|
|
2575
|
+
* marks the entry to be kept again: the removal may have landed after that keep). Answers the refusals. */
|
|
2576
|
+
async function emptyStale(board, key, machine, agent, held, unreachable) {
|
|
2577
|
+
const refused = [];
|
|
2578
|
+
for (const scope of held?.stale ?? []) {
|
|
2579
|
+
const done = await credentialsDoor(board, machine, scope, agent, [], unreachable);
|
|
2580
|
+
if (done.why) { refused.push(done.why); continue; }
|
|
2581
|
+
setHanded(board, key, (entry) => entry && ({ ...entry, stale: (entry.stale ?? []).filter((each) => each !== scope),
|
|
2582
|
+
...(entry.scope === scope ? { digest: null, attempt: randomUUID() } : {}) }));
|
|
2583
|
+
}
|
|
2584
|
+
return refused;
|
|
2585
|
+
}
|
|
2586
|
+
/** Keep `agent`'s records in this board's home for it on `machine` (the stored scope; a launch's computed one, the stored
|
|
2587
|
+
* emptied first): `{ home }`, or `{ why }`. */
|
|
2588
|
+
async function keepAgentCredentialsOn(board, machine, agent, { records = installedAgentRecords(board.root, agent), run = null, task = null, launch = false, unreachable = null } = {}) {
|
|
2589
|
+
const key = credentialsKey(machine, agent), attempt = randomUUID();
|
|
2590
|
+
const held = view(board, handedOf)[key] ?? null;
|
|
2591
|
+
const scope = launch || !held?.scope ? credentialsScope(board) : held.scope;
|
|
2592
|
+
// Recorded before it is asked: an unanswered keep is still the board's to undo, at this scope.
|
|
2593
|
+
setHanded(board, key, (entry) => ({ ...(entry ?? {}), scope, home: entry?.scope === scope ? entry.home ?? null : null, digest: null, run, task, attempt,
|
|
2594
|
+
stale: [...new Set([...(entry?.stale ?? []), ...(entry?.scope && entry.scope !== scope ? [entry.scope] : [])])].filter((each) => each !== scope) }));
|
|
2595
|
+
await emptyStale(board, key, machine, agent, view(board, handedOf)[key], unreachable);
|
|
2596
|
+
const kept = await credentialsDoor(board, machine, scope, agent, records, unreachable);
|
|
2597
|
+
if (kept.why) return kept;
|
|
2598
|
+
setHanded(board, key, (entry) => entry?.attempt === attempt
|
|
2599
|
+
? { ...entry, home: kept.home, digest: credentialsDigest(records), keptAt: new Date().toISOString(), leaseUntil: kept.leaseUntil ?? null } : entry);
|
|
2600
|
+
return { home: kept.home };
|
|
2601
|
+
}
|
|
2602
|
+
/** Tell `machine` to keep none of `agent`'s records for this board, at every scope the entry names; the entry is dropped
|
|
2603
|
+
* only when nothing wrote it since `held` was read, else marked to be decided again. Answers the refusals. */
|
|
2604
|
+
async function removeAgentCredentialsOn(board, key, held, unreachable) {
|
|
2605
|
+
const [machine, agent] = key.split('|');
|
|
2606
|
+
const refused = await emptyStale(board, key, machine, agent, held, unreachable);
|
|
2607
|
+
if (held?.scope) {
|
|
2608
|
+
const done = await credentialsDoor(board, machine, held.scope, agent, [], unreachable);
|
|
2609
|
+
if (done.why) return [...refused, done.why];
|
|
2610
|
+
}
|
|
2611
|
+
setHanded(board, key, (entry) => {
|
|
2612
|
+
if (!entry) return null;
|
|
2613
|
+
if (entry.attempt !== held?.attempt) return { ...entry, digest: null, attempt: randomUUID() };
|
|
2614
|
+
const rest = { ...entry, scope: null, home: null, digest: null, stale: entry.stale ?? [] };
|
|
2615
|
+
return rest.stale.length ? rest : null;
|
|
2616
|
+
});
|
|
2617
|
+
return refused;
|
|
2618
|
+
}
|
|
2619
|
+
/** Whether a kept entry is due again: its records changed, it holds none yet, or half its lease is gone. */
|
|
2620
|
+
function credentialsDue(held, records, now = Date.now()) {
|
|
2621
|
+
if (!held?.scope || held.digest !== credentialsDigest(records) || held.stale?.length) return true;
|
|
2622
|
+
const kept = Date.parse(held.keptAt), until = Date.parse(held.leaseUntil);
|
|
2623
|
+
return !(Number.isFinite(kept) && Number.isFinite(until) && now < kept + (until - kept) / 2);
|
|
2624
|
+
}
|
|
2625
|
+
/** The delivery pass's half: `live` maps each (machine, agent) with a run there that wants its credentials to that run. */
|
|
2626
|
+
async function keepAgentCredentialsCurrent(board, live, report) {
|
|
2627
|
+
const unreachable = new Set();
|
|
2628
|
+
for (const key of new Set([...live.keys(), ...Object.keys(view(board, handedOf))])) {
|
|
2629
|
+
const [machine, agent] = key.split('|');
|
|
2630
|
+
// read again for each: a launch or another pass may have written it since this pass began
|
|
2631
|
+
const held = view(board, handedOf)[key] ?? null, wanted = live.get(key) ?? (runOpen(board, held?.run) ? { run: held.run, task: held.task ?? null } : null);
|
|
2632
|
+
const task = wanted?.task ?? held?.task ?? null;
|
|
2633
|
+
if (wanted) {
|
|
2634
|
+
const records = installedAgentRecords(board.root, agent);
|
|
2635
|
+
if (!credentialsDue(held, records)) continue;
|
|
2636
|
+
const kept = await keepAgentCredentialsOn(board, machine, agent, { records, run: wanted.run, task, unreachable });
|
|
2637
|
+
if (kept.why) report.queued.push([task, `agent ${agent}'s own Workplace credentials could not be kept on ${machine}: ${kept.why}`]);
|
|
2638
|
+
} else {
|
|
2639
|
+
const refused = await removeAgentCredentialsOn(board, key, held, unreachable);
|
|
2640
|
+
if (refused.length) report.queued.push([task, `agent ${agent}'s own Workplace credentials could not be removed from ${machine} (its lease ends them by ${held?.leaseUntil ?? 'a day after their last keep'}): ${refused[0]}`]);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
/** Before a board is archived or deleted: every machine it handed an agent's records is told to keep none. Answers what
|
|
2645
|
+
* could not be removed now (the lease on that machine ends it). */
|
|
2646
|
+
export async function drainAgentCredentials(root, slug) {
|
|
2647
|
+
await knowLocalMachine();
|
|
2648
|
+
const board = openBoard(root, slug);
|
|
2649
|
+
try {
|
|
2650
|
+
board.label = boardLabel(root, slug);
|
|
2651
|
+
const left = [], unreachable = new Set();
|
|
2652
|
+
for (const [key, held] of Object.entries(view(board, handedOf))) {
|
|
2653
|
+
const refused = await removeAgentCredentialsOn(board, key, held, unreachable);
|
|
2654
|
+
if (refused.length) left.push({ machine: key.split('|')[0], agent: key.split('|')[1], leaseUntil: held?.leaseUntil ?? null, reason: refused[0] });
|
|
2655
|
+
}
|
|
2656
|
+
return left;
|
|
2657
|
+
} finally { board.close(); }
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2328
2660
|
async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, orchestration } = {}) {
|
|
2329
2661
|
const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
|
|
2330
2662
|
// Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
|
|
@@ -2344,6 +2676,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2344
2676
|
if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
|
|
2345
2677
|
const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
|
|
2346
2678
|
const how = HARNESSES[harness];
|
|
2679
|
+
// A pane on another machine finds its agent's own Workplace credentials in the agent's home there, which its
|
|
2680
|
+
// SUPERCODE_AGENT_HOME names (the rh2 CLI reads it per call); a machine that could not keep them is said on the card,
|
|
2681
|
+
// and its pane opens as before, acting on no Workplace (never on a person's key).
|
|
2682
|
+
if (meta.agent_id && machine !== localMachine()) {
|
|
2683
|
+
const kept = await keepAgentCredentialsOn(board, machine, meta.agent_id, { run: runId, task: task.id, launch: true });
|
|
2684
|
+
if (kept.home) meta.agent_env = { ...meta.agent_env, SUPERCODE_AGENT_HOME: kept.home };
|
|
2685
|
+
else runTx(board, task.id, runId, (db) => event(db, task.id, 'agent_credentials_unkept', { machine, reason: kept.why }, runId));
|
|
2686
|
+
}
|
|
2347
2687
|
// A card on this machine opens through this machine's own daemon; only another machine is reached with `--on`.
|
|
2348
2688
|
const base = ['open', '--new', how.program, '--detach', `--window=${window}`, ...(machine === localMachine() ? [] : ['--on', task.machine]), '--cwd', task.workspace_path, '--env', 'SUPERCODE_BOARD_PROCESS_GUARD=1',
|
|
2349
2689
|
...Object.entries(meta.agent_env??{}).flatMap(([key,value])=>['--env',`${key}=${value}`])];
|
|
@@ -2369,6 +2709,9 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2369
2709
|
// the round's own fleet reading: a launch never reads the fleet again
|
|
2370
2710
|
const fresh = fleet;
|
|
2371
2711
|
if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
|
|
2712
|
+
// a machine whose record is not current lists no session (continuationPane reads fleet.sessions): no continuation
|
|
2713
|
+
// can be ruled out there, so nothing is resumed beside one
|
|
2714
|
+
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'}`);
|
|
2372
2715
|
const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
2373
2716
|
const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
2374
2717
|
if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
@@ -2467,7 +2810,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2467
2810
|
fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
|
|
2468
2811
|
if (reading.alive && !prev.released && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
|
|
2469
2812
|
}
|
|
2470
|
-
|
|
2813
|
+
// A forgotten session the board released (an adopted one, left running) is still the arc's while it runs: only the
|
|
2814
|
+
// manager's explicit replace_session lets a live one go. Every other forget (a retarget recorded before openCoder kept
|
|
2815
|
+
// the coder) leaves it kept below, as any released session is, and the run is delivered into it; never a fresh
|
|
2816
|
+
// implementer beside it (VO-3755 t_c2d5d21b run 2736). One that is gone is replaced as before.
|
|
2817
|
+
const liveForgotten = prev?.forgotten && prev.released && prev.machine === machine && (prev.harness ?? 'claude-code') === harness
|
|
2818
|
+
&& !view(board, (db) => Number(latestEvent(db, task.id, ['session_forgotten'], 'created_at')?.created_at ?? 0) >= Number(prev.forgotten))
|
|
2819
|
+
&& (prev.pane ? await panePresent(prev.machine, prev.pane) : sessionAlive(fleet, prev)) === true;
|
|
2820
|
+
if (prev?.forgotten && !liveForgotten) {
|
|
2471
2821
|
if (!prev.close_confirmed_at && prev.pane && await panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
|
|
2472
2822
|
replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
|
|
2473
2823
|
}
|
|
@@ -2509,12 +2859,13 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2509
2859
|
if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
|
|
2510
2860
|
if (next === 'keep') {
|
|
2511
2861
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
2512
|
-
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, ...kept } = prev;
|
|
2862
|
+
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, ...kept } = prev;
|
|
2513
2863
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
2514
2864
|
// The common binding below transfers ownership and subscriptions before filing the continuation prompt.
|
|
2515
2865
|
} else if (prev.resumable !== false && await nativeAbsent(prev)) {
|
|
2516
|
-
// nothing to resume and no context to keep
|
|
2517
|
-
|
|
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 };
|
|
2518
2869
|
} else if (prev.resumable !== false) {
|
|
2519
2870
|
const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
|
|
2520
2871
|
const released = view(board,db=>{
|
|
@@ -2522,11 +2873,12 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2522
2873
|
return last?.kind === 'dispatch_released';
|
|
2523
2874
|
});
|
|
2524
2875
|
// Only a session whose run ended in a monitored incident waits for its manager or creator; one a workflow
|
|
2525
|
-
// transition closed (a reviewer after its verdict, an implementer whose card came back) is the slot's
|
|
2526
|
-
//
|
|
2876
|
+
// transition closed (a reviewer after its verdict, an implementer whose card came back) is the slot's deliberate
|
|
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
|
|
2527
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`);
|
|
2528
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
|
|
2529
|
-
// its next round gets that round's
|
|
2881
|
+
// its next round gets that round's brief and PR heads), not only that it was resumed
|
|
2530
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.`;
|
|
2531
2883
|
// a session is resumed in its own harness, whatever the lane's is now
|
|
2532
2884
|
const its = HARNESSES[prev.harness ?? 'claude-code'] ?? HARNESSES['claude-code'];
|
|
@@ -2547,15 +2899,16 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2547
2899
|
if (!session) {
|
|
2548
2900
|
const sid = how.namesItself ? null : randomUUID();
|
|
2549
2901
|
const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
|
|
2550
|
-
// A fresh session is named for its card (`
|
|
2551
|
-
// name, with the start of its id after it as the name's own
|
|
2552
|
-
|
|
2553
|
-
const
|
|
2902
|
+
// A fresh session is named for its card by its readable id (`voice-calls-3f`; a reviewer's `voice-calls-reviewer-3f`),
|
|
2903
|
+
// 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
|
+
// suffix; a kept or resumed session keeps its name.
|
|
2905
|
+
const handle = cardHandleOf(board, task.id), readable = readableIdOf(board, task.id), stem = readable ?? handle;
|
|
2906
|
+
const name = stem ? `${stem}${slot === 'worker' ? '' : `-${slot}`}-${(sid ?? randomUUID()).slice(0, 2)}` : null;
|
|
2554
2907
|
const named = name && NAME_ARGUMENTS[harness] ? NAME_ARGUMENTS[harness](name) : [];
|
|
2555
2908
|
// Codex names the thread after its native first-input receipt identifies it; Teams uses thread/name/set on
|
|
2556
2909
|
// this launch's own app-server, then reads the name back. Continuations receive no naming hint.
|
|
2557
2910
|
session = await openOwned(launchKey, sid, 'fresh', [...base, ...(name && harness === 'codex' ? ['--env', `SUPERCODE_SESSION_NAME=${name}`] : []), '--key', launchKey,
|
|
2558
|
-
'--input', input ?? `Card ${
|
|
2911
|
+
'--input', input ?? `Card ${cardLabel(boardKey(board.root, board.slug), cardNumber(board.db, task.id), task.id, readable)}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...named, ...tail]);
|
|
2559
2912
|
if (replaced) session.replaces = replaced.session_id;
|
|
2560
2913
|
}
|
|
2561
2914
|
session.harness = session.harness ?? harness;
|
|
@@ -2702,7 +3055,13 @@ function rotateLog(file, max, backups) {
|
|
|
2702
3055
|
/** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
|
|
2703
3056
|
async function resolveLocalWorkspace(board, task) {
|
|
2704
3057
|
const root = board.root;
|
|
2705
|
-
if (task.workspace_kind !== 'scratch')
|
|
3058
|
+
if (task.workspace_kind !== 'scratch') {
|
|
3059
|
+
// A branch the board named for the card is claimed until its worktree is made, and the making is recorded once.
|
|
3060
|
+
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.path;
|
|
3064
|
+
}
|
|
2706
3065
|
const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
|
|
2707
3066
|
mkdirSync(dir, { recursive: true });
|
|
2708
3067
|
return dir;
|
|
@@ -2726,10 +3085,46 @@ export function requestAdoption(board, id, who, { workspace = null, assignee = n
|
|
|
2726
3085
|
if (boardCopyOf(board)) throw copyRefusal(board, 'adopts no session');
|
|
2727
3086
|
tx(board, db => {
|
|
2728
3087
|
if (!getTask(db, id)) throw new Error(`no card ${id}`);
|
|
3088
|
+
// a session named by its address or id is checked now; one named otherwise is checked when it is bound
|
|
3089
|
+
const named = /^sc:[^:]+:[^:]+:(.+)$/.exec(String(who))?.[1] ?? (/^[0-9a-f-]{8,}$/i.test(String(who)) ? String(who) : null);
|
|
3090
|
+
const why = coderAsReviewer(db, board, id, { sessionId: named, assignee, role });
|
|
3091
|
+
if (why) throw new Error(coderAsReviewerRefusal(id, statusOf(db, getTask(db, id)), why));
|
|
2729
3092
|
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
|
|
2730
3093
|
});
|
|
2731
3094
|
}
|
|
2732
3095
|
|
|
3096
|
+
/**
|
|
3097
|
+
* Why adopting a session onto card `id` would make the card's own coder its reviewer, or null. A session adopted onto a
|
|
3098
|
+
* 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: an implementer adopted as reviewer is refused). Its coder is a worker-slot session of
|
|
3100
|
+
* 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`). The adoption is refused, never turned into a return: taking a
|
|
3102
|
+
* card out of review is the manager's own act (`return-review`), recorded with its reason.
|
|
3103
|
+
*/
|
|
3104
|
+
function coderAsReviewer(db, board, id, { sessionId = null, assignee = null, role = null, assigneeBefore = undefined } = {}) {
|
|
3105
|
+
const card = getTask(db, id);
|
|
3106
|
+
if (!card) return null;
|
|
3107
|
+
// the card's assignee before this adoption wrote its own (a bind writes --assignee first)
|
|
3108
|
+
const held = assigneeBefore === undefined ? card.assignee : assigneeBefore;
|
|
3109
|
+
const entry = workflowFor(board.root);
|
|
3110
|
+
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 })));
|
|
3112
|
+
if (slot !== 'reviewer') return null;
|
|
3113
|
+
if (role && role !== 'reviewer') return `the adoption asks for its ${role} role`;
|
|
3114
|
+
if (sessionId && ownCoder(db, id, sessionId)) return `${sessionId} is its own coder (a worker or side session of ${id})`;
|
|
3115
|
+
if (assignee) {
|
|
3116
|
+
const coders = new Set(db.prepare(`SELECT DISTINCT profile FROM task_runs WHERE task_id = ? AND profile IS NOT NULL
|
|
3117
|
+
AND COALESCE(json_extract(metadata, '$.supercode.slot'), 'worker') = 'worker'`).all(id).map((r) => r.profile));
|
|
3118
|
+
const implementer = scopeOf(db, board, card, { params: entry.params, lazy: true }).card.implementer;
|
|
3119
|
+
// the implementer a review request recorded (with none recorded, card.implementer is the card's own assignee)
|
|
3120
|
+
if (coders.has(assignee) || (assignee === implementer && implementer !== held)) return `--assignee ${assignee} is its coder's profile`;
|
|
3121
|
+
}
|
|
3122
|
+
return null;
|
|
3123
|
+
}
|
|
3124
|
+
const coderAsReviewerRefusal = (id, status, why) => `${id} is ${status}, where an adopted session is bound as its reviewer, and ${why}. `
|
|
3125
|
+
+ `A reviewer is never the card's own coder. To put its coder back to work, return the card first `
|
|
3126
|
+
+ `(supercode workflow return-review ${id} --reason "<why>"), then adopt the session.`;
|
|
3127
|
+
|
|
2733
3128
|
/** How long a requested adoption waits for its session to appear in the fleet before it fails. */
|
|
2734
3129
|
const ADOPTION_LISTING_WINDOW_S = 180;
|
|
2735
3130
|
|
|
@@ -2746,7 +3141,7 @@ const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_
|
|
|
2746
3141
|
* of two rounds that read one request, one binds it and the other leaves it; the bind and the failure are each written
|
|
2747
3142
|
* only while that claim is the card's latest.
|
|
2748
3143
|
*/
|
|
2749
|
-
export async function completeAdoptions(board, fleet, settings, report
|
|
3144
|
+
export async function completeAdoptions(board, fleet, settings, report) {
|
|
2750
3145
|
const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at
|
|
2751
3146
|
FROM (SELECT DISTINCT task_id FROM task_events) cards CROSS JOIN task_events e ON e.task_id=cards.task_id
|
|
2752
3147
|
WHERE e.kind IN ('dispatch_held','adoption_claimed')
|
|
@@ -2764,7 +3159,7 @@ export async function completeAdoptions(board, fleet, settings, report, lanes =
|
|
|
2764
3159
|
const asked = json(request.payload, {}) ?? {};
|
|
2765
3160
|
const progress = {};
|
|
2766
3161
|
try {
|
|
2767
|
-
const got = await adoptSession(board, row.task_id, asked.session, { request: request.id, open: row.id, progress, workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null
|
|
3162
|
+
const got = await adoptSession(board, row.task_id, asked.session, { request: request.id, open: row.id, progress, workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null});
|
|
2768
3163
|
(report.adopted ??= []).push([row.task_id, got.address]);
|
|
2769
3164
|
} catch (error) {
|
|
2770
3165
|
const message = String(error.message).slice(-400);
|
|
@@ -2781,7 +3176,7 @@ export async function completeAdoptions(board, fleet, settings, report, lanes =
|
|
|
2781
3176
|
continue;
|
|
2782
3177
|
}
|
|
2783
3178
|
// the failure answers the request only while it is still open, or claimed by this round: an adoption that bound and
|
|
2784
|
-
// then failed after
|
|
3179
|
+
// then failed after is bound, and a request another round claimed is that round's
|
|
2785
3180
|
// A failed adoption answers its request and ends the hold the request put on the card: the card is held again
|
|
2786
3181
|
// only if it was held before the request (an incident's hold, kept for its own release), and is otherwise the
|
|
2787
3182
|
// dispatcher's again. A hold that outlived its failed request held cards nobody adopted until a person released them.
|
|
@@ -2792,7 +3187,7 @@ export async function completeAdoptions(board, fleet, settings, report, lanes =
|
|
|
2792
3187
|
return true;
|
|
2793
3188
|
});
|
|
2794
3189
|
// A request another round answered or claimed first is that round's, not a failure of this one: it is noted where
|
|
2795
|
-
// no round prints it. A failure after this round's own claim
|
|
3190
|
+
// no round prints it. A failure after this round's own claim is one.
|
|
2796
3191
|
if (!answered && !progress.claimed) { (report.adoption_skipped ??= []).push([row.task_id, message]); continue; }
|
|
2797
3192
|
report.failed.push([row.task_id, `adoption of ${asked.session}${answered ? '' : ' (bound by this round, then failed)'}: ${message}`]);
|
|
2798
3193
|
}
|
|
@@ -2834,25 +3229,19 @@ async function nativeListing(who) {
|
|
|
2834
3229
|
/** The verb an adoption with no current run fires: the workflow's own `adopt` where it declares one, else its dispatch start. */
|
|
2835
3230
|
const adoptionVerb = (entry) => (entry.workflow.verbs?.adopt || !entry.workflow.run ? 'adopt' : entry.workflow.dispatch.start);
|
|
2836
3231
|
|
|
2837
|
-
|
|
2838
|
-
* The role an adopted session works on its card: a rebound run's own, else the role of the status the adoption's verb
|
|
2839
|
-
* moves the card to, as its claim names it. A card in review adopts its reviewer, never its implementer.
|
|
2840
|
-
*/
|
|
2841
|
-
function adoptedRole(db, board, entry, task, run) {
|
|
2842
|
-
const kept = run?.metadata?.supercode?.role;
|
|
2843
|
-
if (kept) return kept;
|
|
2844
|
-
const verb = adoptionVerb(entry), by = verb === 'adopt' ? 'manager' : 'platform';
|
|
2845
|
-
const status = statusOf(db, task), scope = scopeOf(db, board, task, { params: entry.params });
|
|
2846
|
-
const t = transitionsFor(entry.workflow, status, verb).find((x) => !x.by?.length || x.by.includes(by));
|
|
2847
|
-
return runSpec(entry, t?.target ? String(templateValue(t.target, scope)) : status, task)?.role ?? null;
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
async function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null, lanes = null } = {}) {
|
|
3232
|
+
async function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null } = {}) {
|
|
2851
3233
|
fleet ??= await observeFleet();
|
|
2852
3234
|
// a fleet that could not be read says nothing of the session: the request stays open for the next reading
|
|
2853
3235
|
if (!fleet.ok) throw Object.assign(new Error(`supercode could not read the fleet: ${fleet.error}`), { unread: true });
|
|
2854
|
-
|
|
2855
|
-
|
|
3236
|
+
// A session no current machine lists may be one of a machine whose record is not current: the one its address names,
|
|
3237
|
+
// 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
|
+
const named = /^sc:([^:]+):/.exec(String(who))?.[1], machines = named ? [normalMachine(named)].filter((machine) => fleet.unknown?.has(machine)) : [...(fleet.unknown ?? [])];
|
|
3239
|
+
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? (named && machines.length ? null : await nativeListing(who));
|
|
3240
|
+
if (!found) {
|
|
3241
|
+
// said, and waited on as an unlisted session is, within the listing window
|
|
3242
|
+
const unread = machines.map((machine) => `machine ${machine}'s record is not current: ${fleet.errors?.get(machine) ?? 'no current record'}`).join('; ');
|
|
3243
|
+
throw Object.assign(new Error(`the fleet lists no live session ${who}${unread ? ` (${unread})` : ''}`), { notListed: true });
|
|
3244
|
+
}
|
|
2856
3245
|
const [, host, harness, sessionId] = found.address.split(':');
|
|
2857
3246
|
if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
|
|
2858
3247
|
const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId), { session_id: sessionId, harness });
|
|
@@ -2884,14 +3273,9 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2884
3273
|
const r = await runtimeDoor(normalMachine(host)===localMachine()?null:host,'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
2885
3274
|
dir = (json(r.stdout.trim(), []) ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
|
|
2886
3275
|
}
|
|
2887
|
-
//
|
|
2888
|
-
//
|
|
2889
|
-
|
|
2890
|
-
const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
|
|
2891
|
-
const lane = laneName ? lanes?.[laneName] : null, context = current().facts.SUPERCODE_CONTEXT, team = agentScope(context);
|
|
2892
|
-
// The request is claimed before this adoption's first write outside the board (the agent store, below), in
|
|
2893
|
-
// a transaction that finds it still open and the card's run the one read: a request answered or claimed meanwhile, or
|
|
2894
|
-
// a run that changed, ends it here with nothing written anywhere, and no other round acts on a request claimed here.
|
|
3276
|
+
// Agents are started, not attached (owner, VO-3751). Adoption records card work only: the session
|
|
3277
|
+
// keeps its original identity, profile, hooks, MCP and folder, including when another card's agent owns it.
|
|
3278
|
+
// Claim the request before reading its slot again, so another round cannot bind the same request meanwhile.
|
|
2895
3279
|
const stale = (db) => {
|
|
2896
3280
|
if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
|
|
2897
3281
|
if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
|
|
@@ -2905,14 +3289,8 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2905
3289
|
if (refused) throw refused;
|
|
2906
3290
|
event(db, id, 'adoption_claimed', { request, session: who, ...newClaim(ttl) });
|
|
2907
3291
|
});
|
|
3292
|
+
if (request !== null) noteClaim(board, 'cards', id);
|
|
2908
3293
|
progress.claimed = request !== null;
|
|
2909
|
-
// The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
|
|
2910
|
-
const agent = lane ? await localCardAgent({ root: board.root, handle: cardHandleOf(board, id), readableId: readableIdOf(board, id), org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole}) : null;
|
|
2911
|
-
if(agent)recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
|
|
2912
|
-
// A session another agent already owns is refused before anything is recorded: the run would name one agent and the
|
|
2913
|
-
// agent store another, and every round after would trip on it.
|
|
2914
|
-
const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
|
|
2915
|
-
if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
|
|
2916
3294
|
// Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
|
|
2917
3295
|
// adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
|
|
2918
3296
|
await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
|
|
@@ -2934,56 +3312,46 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2934
3312
|
if (dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
|
|
2935
3313
|
db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
|
|
2936
3314
|
}
|
|
2937
|
-
// Adoption takes the role and slot declared for this card's current stage.
|
|
2938
3315
|
const entry = workflowFor(board.root);
|
|
2939
|
-
const expected = runSpec(entry, statusOf(db, task), task);
|
|
2940
|
-
if (role && role !== expected?.role) throw new Error(`${id} needs its ${expected?.role ?? 'declared'} role, not ${role}`);
|
|
2941
|
-
if (expected?.role === 'reviewer') {
|
|
2942
|
-
// Independence reads history even when its implementer's custody was handed back.
|
|
2943
|
-
const implementer = json(db.prepare(`SELECT metadata FROM task_runs WHERE task_id=?
|
|
2944
|
-
AND COALESCE(json_extract(metadata,'$.supercode.slot'),'worker')='worker'
|
|
2945
|
-
AND json_extract(metadata,'$.supercode.session_id') IS NOT NULL ORDER BY id DESC LIMIT 1`).get(id)?.metadata, {})?.supercode;
|
|
2946
|
-
if (implementer?.session_id === sessionId) throw new Error('a reviewer must be independent of the implementer session');
|
|
2947
|
-
}
|
|
2948
3316
|
takeSessionCustody(db, id, found.address);
|
|
2949
3317
|
event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
|
|
2950
3318
|
// the manager's adoption is the workflow's own `adopt` verb where it declares one, sent as the manager; else its
|
|
2951
3319
|
// dispatch start, which the platform sends for a verified adoption
|
|
2952
3320
|
if (!beforeRun) { const verb = adoptionVerb(entry);
|
|
2953
|
-
fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() },
|
|
2954
|
-
noticeSession: { address: found.address, session_id: sessionId, machine: host, harness } }); }
|
|
3321
|
+
fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() }, adopting: true }); }
|
|
2955
3322
|
const runId = getTask(db, id).current_run_id;
|
|
2956
3323
|
if (!runId) throw new Error(`${id} waits on parents that are not done`);
|
|
2957
|
-
//
|
|
3324
|
+
// A ready/todo card has no run role until adoption fires its transition. Validate the actual run's
|
|
3325
|
+
// role here, in the same transaction, so refusal rolls back both the transition and the attachment.
|
|
2958
3326
|
const runRole = currentRun(db, id)?.metadata?.supercode?.role;
|
|
2959
|
-
if (
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
3327
|
+
if (role && role !== runRole) throw new Error(`${id} needs its ${runRole ?? 'declared'} role, not ${role}`);
|
|
3328
|
+
if (runRole === 'reviewer') {
|
|
3329
|
+
// Independence reads history even when its implementer's custody was handed back: no worker session of any run of
|
|
3330
|
+
// the card, none of its side sessions, and no profile its coder ran as (t_2a2ef475: a side session adopted with
|
|
3331
|
+
// --assignee coder-claude onto the card in review was bound as its reviewer, run 2732).
|
|
3332
|
+
const why = coderAsReviewer(db, board, id, { sessionId, assignee, role, assigneeBefore: task.assignee ?? null });
|
|
3333
|
+
if (why) throw new Error(coderAsReviewerRefusal(id, statusOf(db, task), why));
|
|
3334
|
+
}
|
|
3335
|
+
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() };
|
|
2963
3336
|
// a deferred launch was never issued: the adopted session is the run's, and no later round launches another
|
|
2964
3337
|
const owned = [beforeSession, was].find(s => s && s.pane === session.pane && (sameIdentity(s) || s === beforeSession && keyedPane));
|
|
2965
3338
|
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session,
|
|
3339
|
+
// A deferred or rebound run may retain launch intent. It must not make the delivery pass bind this
|
|
3340
|
+
// attached session to that intent's agent; the session's existing ownership stays with its starter.
|
|
3341
|
+
agent_id: null, agent_org: null, agent_context: null, agent_profile_intent: null, agent_env: null,
|
|
2966
3342
|
detached_at: null, detached_to: null, launch_key: owned?.launch_key ?? null, launch_pending: false, launch_error: null,
|
|
2967
3343
|
launch_deferred: false, launch_deferred_error: null, slot: adoptionSlot, retain_after_run: false,
|
|
2968
3344
|
close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, close_failed_at: null, close_claim: null,
|
|
2969
|
-
released: false, forgotten: false, monitor_held: false, missing_since: null });
|
|
3345
|
+
released: false, forgotten: false, monitor_held: false, missing_since: null, handed_back_from: null, handed_back_run: null });
|
|
2970
3346
|
transferRetainedSession(db, id, runId, session);
|
|
2971
3347
|
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
|
|
3348
|
+
// an attached session is told its role's brief, the same one a launched session starts with
|
|
3349
|
+
const card = getTask(db, id), told = roleBrief(db, board, entry, card, statusOf(db, card), 'pane');
|
|
3350
|
+
if (told?.prompt) queueBrief(db, board, card, runId, found.address, told, 'attached');
|
|
2972
3351
|
replaceWorkerSubscriptions(db, id, found.address);
|
|
2973
3352
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
|
|
2974
3353
|
return { ...session, run: runId, workspace: dir };
|
|
2975
3354
|
});
|
|
2976
|
-
if (agent) {
|
|
2977
|
-
recordAgentRun(board.root, team.org, agent.identity.id, { session: `${harness}:${sessionId}`, machine: host, run: adopted.run });
|
|
2978
|
-
// Teams learns whose the session is the way a launch tells it (ADR 0013, "launched or adopted"): the session's machine
|
|
2979
|
-
// queues it with the same agent environment a launch carries, and binds it when Teams answers. The adoption stands
|
|
2980
|
-
// either way; the card says whether the machine took it.
|
|
2981
|
-
const environment = agentEnvironment(agent, { root: board.root, context: normalMachine(host) === localMachine() ? localReaderContext(team.org, agent.identity.id) ?? undefined : undefined });
|
|
2982
|
-
const r = await doorOn(host, 'harness.v1.sessions.own', { session: `${harness}:${sessionId}`, environment }, { timeout: 30_000 });
|
|
2983
|
-
const answer = r.code === 0 ? lastJson(r.stdout) : null;
|
|
2984
|
-
tx(board, (db) => event(db, id, answer?.queued === true ? 'agent_session_queued' : 'agent_session_unqueued',
|
|
2985
|
-
{ session: found.address, agent: agent.identity.id, ...(answer?.queued === true ? {} : { reason: String(answer?.reason ?? (r.stderr || r.stdout || 'the machine did not answer')).slice(0, 300) }) }, adopted.run));
|
|
2986
|
-
}
|
|
2987
3355
|
return adopted;
|
|
2988
3356
|
}
|
|
2989
3357
|
|
|
@@ -3118,7 +3486,8 @@ export async function fileBoardMail(board, report, taskId = null) {
|
|
|
3118
3486
|
const first = mail.members[0];
|
|
3119
3487
|
try {
|
|
3120
3488
|
const task = view(board, db => getTask(db, first.task_id));
|
|
3121
|
-
const
|
|
3489
|
+
const several = mail.requester && !mail.message_id && new Set(mail.members.map((m) => m.task_id)).size > 1;
|
|
3490
|
+
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));
|
|
3122
3491
|
const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
3123
3492
|
tx(board, db => {
|
|
3124
3493
|
if (filed.detached) {
|
|
@@ -3144,26 +3513,42 @@ const MAX_SEND_FAILURES = 12;
|
|
|
3144
3513
|
* What an audience is told of an event: the workflow's `notify[audience][kind]` template over the card, the event's
|
|
3145
3514
|
* payload and (for a comment) the comment; null when the workflow names no such notice.
|
|
3146
3515
|
*/
|
|
3147
|
-
export function noticeOf(entry, audience, kind, task, payload, comment = null) {
|
|
3516
|
+
export function noticeOf(entry, audience, kind, task, payload, comment = null, recipient = null) {
|
|
3148
3517
|
const template = entry.workflow.notify?.[audience]?.[kind];
|
|
3149
3518
|
if (template === undefined || template === null) return null;
|
|
3150
3519
|
const cut = (text, n) => String(text ?? '').slice(0, Number(n));
|
|
3151
|
-
|
|
3152
|
-
|
|
3520
|
+
// `recipient`: who is told (its subscription's address) and whether the comment @mentions it, for an audience that
|
|
3521
|
+
// hears only what names it (an empty notice is not filed)
|
|
3522
|
+
const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut, recipient: recipient ?? { address: null, mentioned: false } });
|
|
3523
|
+
return text.trim() ? numbered(task?.key, task?.number, text, noticeName(task)) : null;
|
|
3153
3524
|
}
|
|
3154
3525
|
|
|
3155
3526
|
/**
|
|
3156
3527
|
* Who hears a card without having asked: each running card's session hears its card and its subtasks (a subscription a
|
|
3157
3528
|
* launch or a missed fleet read lost comes back), and each of the workflow's managers (`params.managers`) hears every
|
|
3158
|
-
* open
|
|
3529
|
+
* open arc (not its subtasks) as the `manager` audience, whoever filed it, when the workflow's notify names one.
|
|
3159
3530
|
*/
|
|
3160
3531
|
function ensureSubscriptions(board, entry) {
|
|
3161
3532
|
const managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : [];
|
|
3162
3533
|
const toManagers = Object.keys(entry.workflow.notify?.manager ?? {}).length > 0;
|
|
3163
3534
|
tx(board, (db) => {
|
|
3164
3535
|
reconcileDependencySubscriptions(db);
|
|
3165
|
-
|
|
3166
|
-
|
|
3536
|
+
// A subtask's watchers are its own (its creator, its worker, whoever comments on it or is mentioned there), as an
|
|
3537
|
+
// issue tracker's sub-task's are: the manager follows arcs, and an arc's follower is not copied onto its subtasks.
|
|
3538
|
+
// Such copies made before are removed here.
|
|
3539
|
+
db.prepare(`DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND thread_id = ''
|
|
3540
|
+
AND task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of IS NOT NULL)
|
|
3541
|
+
AND ((json_extract(delivery_metadata, '$.role') = 'manager' AND json_extract(delivery_metadata, '$.own') IS NULL)
|
|
3542
|
+
OR (COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') = 'creator'
|
|
3543
|
+
AND json_extract(delivery_metadata, '$.own') IS NULL
|
|
3544
|
+
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))
|
|
3545
|
+
AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = kanban_notify_subs.task_id AND e.kind = 'created'
|
|
3546
|
+
AND json_extract(e.payload, '$.creator') IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
|
|
3547
|
+
AND EXISTS (SELECT 1 FROM kanban_notify_subs arc JOIN supercode_cards c ON c.subtask_of = arc.task_id
|
|
3548
|
+
WHERE c.task_id = kanban_notify_subs.task_id AND arc.chat_id = kanban_notify_subs.chat_id AND arc.platform = 'supercode')))`).run();
|
|
3549
|
+
for (const t of db.prepare(`SELECT t.id, t.current_run_id, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
3550
|
+
WHERE t.status NOT IN ('done', 'archived')`).all()) {
|
|
3551
|
+
if (toManagers && !t.subtask_of) for (const m of managers) subscribeIn(db, t.id, m, 'manager');
|
|
3167
3552
|
if (!t.current_run_id) continue;
|
|
3168
3553
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(t.current_run_id)?.metadata, {})?.supercode;
|
|
3169
3554
|
const address = addressOf(session);
|
|
@@ -3216,7 +3601,7 @@ function settleStateRequestAnswers(board, entry, report) {
|
|
|
3216
3601
|
const text = `${a.task_id}: your request-state ${a.state ?? ''} (${a.question}) was answered by ${a.answered_by ?? 'the manager'} in ${a.answer}; ${outcome}.\n${String(a.text ?? '').trim()}\nRead the card: supercode workflow show ${a.task_id} --root ${board.root}`;
|
|
3217
3602
|
const effect = { kind: 'send_to_actor', id: a.task_id, runId: a.run_id, address: to, sourceEvent: id, verb: 'state_request_answered',
|
|
3218
3603
|
messageKey: `board-state-answer-${a.task_id}-${id}`,
|
|
3219
|
-
text: identifyNotice({ id, kind: 'state_request_answered', run_id: a.run_id, created_at: now() }, numbered(boardKey(board.root, board.slug), cardNumber(db, a.task_id), text)) };
|
|
3604
|
+
text: identifyNotice({ id, kind: 'state_request_answered', run_id: a.run_id, created_at: now() }, numbered(boardKey(board.root, board.slug), cardNumber(db, a.task_id), text, cardName(db, a.task_id))) };
|
|
3220
3605
|
db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
3221
3606
|
}
|
|
3222
3607
|
});
|
|
@@ -3302,81 +3687,204 @@ const closePending = ({ held, latest }, address) => {
|
|
|
3302
3687
|
&& !closeRefused(session);
|
|
3303
3688
|
};
|
|
3304
3689
|
|
|
3690
|
+
/** A subscription's role on its card: worker, dependency, manager, participant, else creator. */
|
|
3691
|
+
const followerRole = (sub) => ['worker', 'dependency', 'manager', 'participant'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
|
|
3692
|
+
|
|
3305
3693
|
/**
|
|
3306
|
-
* The
|
|
3307
|
-
*
|
|
3308
|
-
*
|
|
3309
|
-
*
|
|
3310
|
-
*
|
|
3311
|
-
*
|
|
3312
|
-
*
|
|
3313
|
-
*
|
|
3694
|
+
* The subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event per
|
|
3695
|
+
* session naming its cards, whatever role they follow a card in (a dependency subscription is its arc's, moved with the
|
|
3696
|
+
* arc's session by reconcileDependencySubscriptions). A session has ended when no unended or current run works in it,
|
|
3697
|
+
* the fleet does not list it running, its latest run is not a release (a released session is a person's own, left
|
|
3698
|
+
* running), and the board closed it (that run's confirmed close) or it is gone (no transcript, process or pane, read
|
|
3699
|
+
* whole: subscriberGone). Everything is read inside the transaction, so a run that took the session meanwhile keeps it.
|
|
3700
|
+
* Nothing waits for an ended session: what it was not told is not kept for it, and a session resumed later follows a
|
|
3701
|
+
* card again from then (its run's launch, its own comment, a mention). A session that ended any other way keeps its
|
|
3702
|
+
* subscriptions and is passed over while the fleet does not list it (`notify`), and what it misses meanwhile grows old
|
|
3703
|
+
* there (liveSeconds), never told late.
|
|
3314
3704
|
*/
|
|
3315
|
-
async function
|
|
3316
|
-
|
|
3705
|
+
async function endEndedSubscriptions(board, fleet, report) {
|
|
3706
|
+
const followers = "platform = 'supercode' AND json_extract(delivery_metadata, '$.dependent_card') IS NULL AND COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') != 'dependency'";
|
|
3707
|
+
// each native subscriber's liveness is read before the write
|
|
3317
3708
|
const aliveNow = new Map(), goneNow = new Map();
|
|
3318
|
-
if (fleet.ok) for (const { chat_id: address } of board.db.prepare(
|
|
3709
|
+
if (fleet.ok) for (const { chat_id: address } of board.db.prepare(`SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE ${followers}`).all()) {
|
|
3710
|
+
if (!sessionAt(address)) continue;
|
|
3319
3711
|
aliveNow.set(address, await subscriberAlive(fleet, address));
|
|
3320
3712
|
if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
|
|
3321
3713
|
}
|
|
3322
3714
|
tx(board, (db) => {
|
|
3323
3715
|
const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
|
|
3324
|
-
const ended = new Map();
|
|
3325
|
-
for (const row of db.prepare(
|
|
3326
|
-
const
|
|
3327
|
-
|
|
3716
|
+
const ended = new Map(), records = new Map();
|
|
3717
|
+
for (const row of db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE ${followers}`).all()) {
|
|
3718
|
+
const address = row.chat_id;
|
|
3719
|
+
if (!sessionAt(address)) continue;
|
|
3720
|
+
if (!records.has(address)) records.set(address, sessionRecord(db, address));
|
|
3721
|
+
const { held, latest } = records.get(address);
|
|
3722
|
+
const record = latest.get(address);
|
|
3328
3723
|
// a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
|
|
3329
|
-
if (
|
|
3724
|
+
if (record?.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
|
|
3330
3725
|
// the board closed it, or it is gone (no transcript, process or pane, read whole: subscriberGone)
|
|
3331
|
-
const evidence = record
|
|
3332
|
-
: goneNow.has(address) ? { by: 'gone', run: record
|
|
3726
|
+
const evidence = record?.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at }
|
|
3727
|
+
: goneNow.has(address) ? { by: 'gone', run: record?.run ?? null, read_at: goneNow.get(address), machine: sessionAt(address)?.machine ?? null } : null;
|
|
3333
3728
|
if (!evidence || !remove.run(row.task_id, address, row.thread_id).changes) continue;
|
|
3334
|
-
if (!ended.has(address)) ended.set(address, { record, evidence, cards: [] });
|
|
3729
|
+
if (!ended.has(address)) ended.set(address, { record, evidence, cards: [], roles: new Set() });
|
|
3335
3730
|
ended.get(address).cards.push(row.task_id);
|
|
3731
|
+
ended.get(address).roles.add(json(row.delivery_metadata, {})?.role ?? 'creator');
|
|
3336
3732
|
}
|
|
3337
|
-
for (const [address, { record, evidence, cards }] of ended) {
|
|
3338
|
-
event(db, record
|
|
3733
|
+
for (const [address, { record, evidence, cards, roles }] of ended) {
|
|
3734
|
+
event(db, record?.task_id ?? cards[0], 'notice_unsubscribed', { address, role: roles.size === 1 ? [...roles][0] : [...roles].sort(), reason: 'the session has ended', evidence, cards: cards.slice(0, 50), count: cards.length }, record?.run ?? null);
|
|
3339
3735
|
for (const card of cards) (report.subscriptions_ended ??= []).push([card, address]);
|
|
3340
3736
|
}
|
|
3341
3737
|
});
|
|
3342
3738
|
}
|
|
3343
3739
|
|
|
3740
|
+
/**
|
|
3741
|
+
* One follower, one subscription: a session that is an agent's main session, or was one (its former mains), follows a
|
|
3742
|
+
* card under its agent's address (engine.followerAddress), read from each agent the board knows (its managers, and each
|
|
3743
|
+
* agent address that follows a card). Its subscriptions move to that address, starting from each card's last event
|
|
3744
|
+
* (nothing it was not told is carried over), and where the agent already follows the card its own subscription stands
|
|
3745
|
+
* alone. A worker's subscription is its run's session's and stays where it is.
|
|
3746
|
+
*/
|
|
3747
|
+
async function foldAgentSessions(board, entry, report) {
|
|
3748
|
+
const agents = new Set([...[].concat(entry.params.managers ?? []).map(String),
|
|
3749
|
+
...board.db.prepare("SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id LIKE 'sc:%:agent:%'").all().map((r) => r.chat_id)].filter((a) => agentAt(a)));
|
|
3750
|
+
const agentOf = new Map();
|
|
3751
|
+
for (const agent of agents) {
|
|
3752
|
+
await agentMain(agent);
|
|
3753
|
+
for (const session of agentMains.get(agent)?.sessions ?? []) if (sessionAt(session) && !agentOf.has(session)) agentOf.set(session, agent);
|
|
3754
|
+
}
|
|
3755
|
+
if (!agentOf.size) return;
|
|
3756
|
+
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
|
+
const moved = new Map();
|
|
3760
|
+
for (const row of rows) {
|
|
3761
|
+
const metadata = json(row.delivery_metadata, {}) ?? {};
|
|
3762
|
+
if (['worker', 'dependency'].includes(metadata.role) || metadata.dependent_card) continue;
|
|
3763
|
+
const agent = agentOf.get(row.chat_id);
|
|
3764
|
+
const head = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(row.task_id).c;
|
|
3765
|
+
const { notice_wait: _wait, failures: _failures, ...kept } = metadata;
|
|
3766
|
+
db.prepare(`INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
3767
|
+
VALUES (?, 'supercode', ?, ?, 'dm', 'notify+wake', ?, ?, ?)`).run(row.task_id, agent, row.thread_id, JSON.stringify(kept), now(), head);
|
|
3768
|
+
db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?").run(row.task_id, row.chat_id, row.thread_id);
|
|
3769
|
+
if (!moved.has(row.chat_id)) moved.set(row.chat_id, { agent, cards: [] });
|
|
3770
|
+
moved.get(row.chat_id).cards.push(row.task_id);
|
|
3771
|
+
}
|
|
3772
|
+
for (const [address, { agent, cards }] of moved) {
|
|
3773
|
+
event(db, cards[0], 'notice_subscriptions_folded', { address, agent, reason: `${address} is ${agent}'s main session or a former one: it follows cards as ${agent}`, cards: cards.slice(0, 50), count: cards.length });
|
|
3774
|
+
for (const card of cards) (report.subscriptions_folded ??= []).push([card, address, agent]);
|
|
3775
|
+
}
|
|
3776
|
+
});
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
/** The batching window, by default (params.notice_digest_seconds): what happens on one card within it after a notice
|
|
3780
|
+
* of that card is one message to each recipient at the window's end, as an issue tracker batches an issue's changes. */
|
|
3781
|
+
const NOTICE_DIGEST_SECONDS = 60;
|
|
3782
|
+
/** The flood ceiling: at most this many notice messages reach one recipient in a window; past it, the rest are one
|
|
3783
|
+
* summary message naming their cards (params.notice_ceiling). */
|
|
3784
|
+
const NOTICE_CEILING_DEFAULT = 10;
|
|
3785
|
+
/** A card's batch shows at most this many notices in full, each at most this many characters; the card holds the rest. */
|
|
3786
|
+
const CARD_DIGEST_ITEMS = 10;
|
|
3787
|
+
const CARD_DIGEST_TEXT = 2000;
|
|
3788
|
+
/** The role a session hears an event in when it follows the card more than one way: the first here that hears its kind. */
|
|
3789
|
+
const NOTICE_RANK = ['worker', 'manager', 'dependency', 'creator', 'participant'];
|
|
3790
|
+
const digestSeconds = (entry) => Math.max(Number(entry.params.notice_digest_seconds) || NOTICE_DIGEST_SECONDS, 1);
|
|
3791
|
+
/**
|
|
3792
|
+
* How old an event may be and still be told: its batching window and three delivery passes after it (one a dispatch
|
|
3793
|
+
* interval), never past the workflow's notice_max_age_seconds. An older one is history: passed over, never filed, and
|
|
3794
|
+
* read on its card.
|
|
3795
|
+
*/
|
|
3796
|
+
function liveSeconds(entry) {
|
|
3797
|
+
const live = digestSeconds(entry) + 3 * Math.max(Number(entry.params.dispatch_interval_seconds) || 60, 1);
|
|
3798
|
+
const max = Number(entry.params.notice_max_age_seconds) || null;
|
|
3799
|
+
return max ? Math.min(max, live) : live;
|
|
3800
|
+
}
|
|
3801
|
+
const cutText = (text, n) => { const t = String(text).trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
|
3802
|
+
|
|
3344
3803
|
/**
|
|
3345
3804
|
* Each subscriber's notices, filed in its mailbox (D138 step 6, D140): the dispatcher's job ends at the filing, a local
|
|
3346
|
-
* write under
|
|
3347
|
-
*
|
|
3348
|
-
*
|
|
3349
|
-
*
|
|
3350
|
-
*
|
|
3351
|
-
*
|
|
3352
|
-
*
|
|
3805
|
+
* write under its fixed key; the mailbox delivers it to the session, on this machine or through Teams on another.
|
|
3806
|
+
*
|
|
3807
|
+
* - Nothing historical is sent. A subscription's cursor moves past every event of its card it has read, heard or not,
|
|
3808
|
+
* so a kind or a role it hears later never makes the card's earlier events news, and an event older than
|
|
3809
|
+
* liveSeconds is passed over.
|
|
3810
|
+
* - One event reaches one session once, whatever addresses it follows the card under (an agent's address is its main
|
|
3811
|
+
* session's), in the role ranked first in NOTICE_RANK that hears it.
|
|
3812
|
+
* - Batching, per recipient and card: a card's notices are one message; a card told within the last window
|
|
3813
|
+
* (notice_digest_seconds, measured between delivery passes) waits, cursors unmoved, and what happened on it meanwhile
|
|
3814
|
+
* is one message when the window ends.
|
|
3815
|
+
* - The ceiling: at most NOTICE_CEILING messages reach one recipient in a window; past it, the rest are one summary
|
|
3816
|
+
* message naming their cards, recorded as notice_flood_collapsed. No defect upstream can mail one session more.
|
|
3817
|
+
*
|
|
3818
|
+
* A filing that failed leaves its cursors before it, and the next pass files it. A session the board's own record says
|
|
3819
|
+
* has ended (recordedEnded) is filed nothing, and neither is a worker whose close the board has asked and not yet
|
|
3820
|
+
* confirmed (closePending). The mail router's current session listing is also positive liveness evidence, including
|
|
3821
|
+
* when pane/discovery liveness cannot be read. An unread listing never proves a session absent.
|
|
3353
3822
|
*/
|
|
3354
3823
|
async function notify(board, fleet, report, entry) {
|
|
3355
|
-
await
|
|
3356
|
-
|
|
3824
|
+
await endEndedSubscriptions(board, fleet, report);
|
|
3825
|
+
await foldAgentSessions(board, entry, report);
|
|
3826
|
+
const passAt = now(), window = digestSeconds(entry), live = liveSeconds(entry);
|
|
3827
|
+
const NOTICE_CEILING = Math.max(Number(entry.params.notice_ceiling) || NOTICE_CEILING_DEFAULT, 1);
|
|
3828
|
+
const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
|
|
3829
|
+
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);
|
|
3831
|
+
const rank = (sub) => NOTICE_RANK.indexOf(followerRole(sub));
|
|
3832
|
+
// cursors moved past what was read with nothing to tell, written together after the pass
|
|
3833
|
+
const quiet = [];
|
|
3834
|
+
// recipient session → { recipient, to, items, subs: [{ sub, through }], wake }
|
|
3835
|
+
const digests = new Map();
|
|
3836
|
+
// `<recipient> <event id>`: one event reaches one session once
|
|
3837
|
+
const claimed = new Set();
|
|
3838
|
+
// and across passes: an event the board recorded filed to any address of the same session is not told again
|
|
3839
|
+
const filedBefore = (taskId, eventId, aliases) => Boolean(board.db.prepare(`SELECT 1 FROM task_events WHERE task_id = ? AND kind = 'notice_filed'
|
|
3840
|
+
AND json_extract(payload, '$.source_event') = ? AND json_extract(payload, '$.address') IN (${aliases.map(() => '?').join(', ')}) LIMIT 1`).get(taskId, eventId, ...aliases));
|
|
3841
|
+
const read = [];
|
|
3842
|
+
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode').sort((a, b) => rank(a) - rank(b))) {
|
|
3357
3843
|
await yieldToOwner();
|
|
3358
3844
|
if (stopping) break;
|
|
3359
3845
|
if (view(board, db => personCustody(db, sub.chat_id))) continue;
|
|
3360
3846
|
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3847
|
+
// an unsubscribed follower is told nothing until a mention or its own comment brings it back (engine.follow)
|
|
3848
|
+
if (sub.delivery_metadata?.unsubscribed) continue;
|
|
3849
|
+
const role = followerRole(sub);
|
|
3850
|
+
// what it hears: its role's kinds, and those of a role it held before taking this one (engine.subscribeIn `also`),
|
|
3851
|
+
// each told in the first of its roles whose workflow names that kind
|
|
3852
|
+
const roles = [role, ...[].concat(sub.delivery_metadata?.also ?? []).filter((r) => typeof r === 'string' && r !== role)];
|
|
3853
|
+
const kinds = [...new Set(roles.flatMap((r) => heardBy(entry, r)))];
|
|
3854
|
+
const toldAs = (kind) => roles.find((r) => heardBy(entry, r).includes(kind)) ?? role;
|
|
3855
|
+
// the card's last event, read before its news: the cursor moves to it past every event, heard or not
|
|
3856
|
+
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 }); noticeWait(board, sub, report); continue; }
|
|
3364
3858
|
// Read pending news before liveness: a subscription with nothing to file is not waiting on delivery.
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3859
|
+
// the page starts past every event already history (older than liveSeconds), so a long backlog never holds back
|
|
3860
|
+
// what is news now
|
|
3861
|
+
// A follower's news is history past liveSeconds; a worker's or a dependency's (a verdict it must act on, its run
|
|
3862
|
+
// changed) only past notice_max_age_seconds, so a worker whose liveness could not be read for minutes is still told.
|
|
3863
|
+
const horizon = ['worker', 'dependency'].includes(role) ? Math.max(live, Number(entry.params.notice_max_age_seconds) || live) : live;
|
|
3864
|
+
const historyThrough = Number(board.db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ? AND id > ? AND id <= ? AND created_at < ?')
|
|
3865
|
+
.get(sub.task_id, sub.last_event_id, head, passAt - horizon).c);
|
|
3866
|
+
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
|
+
.all(sub.task_id, Math.max(Number(sub.last_event_id), historyThrough), head, ...kinds);
|
|
3868
|
+
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 }); noticeWait(board, sub, report); continue; }
|
|
3368
3870
|
const record = view(board, db => sessionRecord(db, sub.chat_id));
|
|
3369
3871
|
// Events name the acting native session; subscriptions may name its agent.
|
|
3370
3872
|
// Use the daemon's retained binding, including former mains, for both
|
|
3371
3873
|
// coalescing and delivery. An unread binding proves no additional alias.
|
|
3372
3874
|
const recipientActors = new Set([sub.chat_id]);
|
|
3875
|
+
let recipient = sub.chat_id;
|
|
3373
3876
|
if (agentAt(sub.chat_id)) {
|
|
3374
|
-
await agentMain(sub.chat_id);
|
|
3877
|
+
const main = await agentMain(sub.chat_id);
|
|
3878
|
+
if (main && sessionAt(main)) recipient = main;
|
|
3375
3879
|
for (const address of agentMains.get(sub.chat_id)?.sessions ?? []) recipientActors.add(address);
|
|
3376
3880
|
}
|
|
3377
3881
|
const ownAct = payload => Boolean(payload?.by && recipientActors.has(payload.by));
|
|
3882
|
+
// the addresses of the same session: its agent's (or, for a native session, every agent whose main or former main it
|
|
3883
|
+
// is), as this pass read their bindings
|
|
3884
|
+
const aliases = new Set([...recipientActors, recipient]);
|
|
3885
|
+
if (!agentAt(sub.chat_id)) for (const [agent, read] of agentMains) if (agentAt(agent) && (read?.sessions ?? []).includes(sub.chat_id)) aliases.add(agent);
|
|
3378
3886
|
// a worker the board is closing is filed nothing: its cursor stays, so a close that is refused or a session resumed
|
|
3379
|
-
// is told what it missed, and a close that lands ends its subscription (
|
|
3887
|
+
// is told what it missed, and a close that lands ends its subscription (endEndedSubscriptions)
|
|
3380
3888
|
if (role === 'worker' && closePending(record, sub.chat_id)) {
|
|
3381
3889
|
noticeWait(board, sub, report, 'its session close is pending; its notices wait'); continue;
|
|
3382
3890
|
}
|
|
@@ -3397,70 +3905,136 @@ async function notify(board, fleet, report, entry) {
|
|
|
3397
3905
|
// unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
|
|
3398
3906
|
if (alive === null) { noticeWait(board, sub, report, 'its liveness could not be read; its notices wait until it can be'); continue; }
|
|
3399
3907
|
} else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
|
|
3400
|
-
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's
|
|
3401
|
-
|
|
3908
|
+
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's;
|
|
3909
|
+
// `person` the person it is assigned to, if any)
|
|
3910
|
+
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; });
|
|
3402
3911
|
// a worker whose run has ended is not woken by a notice (a second session would start working the card beside
|
|
3403
3912
|
// the one the next run gets): the notice is filed for its next turn, and the next run's brief carries the card's
|
|
3404
3913
|
// thread. A subtask has no run of its own: its worker works in its arc's run.
|
|
3405
3914
|
const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, task?.subtask_of ?? sub.task_id)?.metadata?.supercode) === sub.chat_id);
|
|
3406
3915
|
// a Claude session always starts a turn when mail reaches it, so its mail is always delivered now
|
|
3407
|
-
const wake = String(sub.chat_id).includes(':claude-code:') || (working && !(!entry.workflow.run && sub.delivery_mode === 'notify'));
|
|
3408
|
-
const
|
|
3409
|
-
const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
|
|
3410
|
-
const forward = (to) => tx(board, (db) => db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(to, ...identity, to));
|
|
3411
|
-
// an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never filed
|
|
3412
|
-
const maxAge = Number(entry.params.notice_max_age_seconds) || null;
|
|
3413
|
-
const stale = (ev) => maxAge && now() - Number(ev.created_at) > maxAge;
|
|
3916
|
+
const wake = String(sub.chat_id).includes(':claude-code:') || String(recipient).includes(':claude-code:') || (working && !(!entry.workflow.run && sub.delivery_mode === 'notify'));
|
|
3917
|
+
const stale = (ev) => passAt - Number(ev.created_at) > horizon;
|
|
3414
3918
|
// A backlog (a restart, a pass that did not run) is told as it stands now: news is coalesced by its subject, not its
|
|
3415
3919
|
// kind alone (a later `assigned` says what an earlier one did, but each subtask added, dependency linked or unlinked
|
|
3416
3920
|
// is its own fact, keyed by the card it names); a comment or an answer is always its own message. Only events that
|
|
3417
3921
|
// would be told count: a stale one or the recipient's own act never supersedes one that would be.
|
|
3418
3922
|
const subjectOf = (ev) => { const p = json(ev.payload, {}) ?? {}; return ['commented', 'answered'].includes(ev.kind) ? `${ev.kind}:${ev.id}` : `${ev.kind}:${p.subtask ?? p.parent ?? p.child ?? ''}`; };
|
|
3419
3923
|
const latest = new Map(events.filter((ev) => !stale(ev) && !ownAct(json(ev.payload, {}))).map((ev) => [subjectOf(ev), ev.id]));
|
|
3420
|
-
|
|
3924
|
+
const items = [];
|
|
3925
|
+
for (const ev of events) {
|
|
3926
|
+
const p = json(ev.payload, {}) ?? {};
|
|
3927
|
+
if (stale(ev)) { report.stale_notices_passed = (report.stale_notices_passed ?? 0) + 1; continue; }
|
|
3928
|
+
// nobody is told of their own act
|
|
3929
|
+
if (ownAct(p)) continue;
|
|
3930
|
+
if (latest.get(subjectOf(ev)) !== ev.id) { report.superseded_notices_passed = (report.superseded_notices_passed ?? 0) + 1; continue; }
|
|
3931
|
+
// the event is this recipient's in this role (the first that hears it); no other subscription of its tells it again
|
|
3932
|
+
const claim = `${recipient} ${ev.id}`;
|
|
3933
|
+
if (claimed.has(claim) || filedBefore(sub.task_id, ev.id, [...aliases])) { report.duplicate_notices_passed = (report.duplicate_notices_passed ?? 0) + 1; continue; }
|
|
3934
|
+
let text;
|
|
3935
|
+
if (ev.kind === 'commented') {
|
|
3936
|
+
if (recipientActors.has(p.author)) continue;
|
|
3937
|
+
const c = board.db.prepare('SELECT author, body FROM task_comments WHERE task_id = ? AND author = ? AND created_at >= ? ORDER BY id LIMIT 1').get(sub.task_id, p.author, ev.created_at);
|
|
3938
|
+
// whether the comment @mentions this recipient (its address, its agent, a session it is), for a role that hears
|
|
3939
|
+
// only what names it
|
|
3940
|
+
const named = c ? view(board, (db) => mentioned(db, entry, c.body)).some((address) => recipientActors.has(address) || address === recipient) : false;
|
|
3941
|
+
text = c ? noticeOf(entry, toldAs(ev.kind), ev.kind, task, p, c, { address: sub.chat_id, mentioned: named }) : null;
|
|
3942
|
+
} else text = noticeOf(entry, toldAs(ev.kind), ev.kind, task, p, null, { address: sub.chat_id, mentioned: false });
|
|
3943
|
+
// claimed once it says something: a role whose notice of it is empty leaves it to the session's other subscriptions
|
|
3944
|
+
if (text) { claimed.add(claim); items.push({ sub, ev, text, body: ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text, task }); }
|
|
3945
|
+
}
|
|
3946
|
+
read.push({ sub, role, task, kinds });
|
|
3947
|
+
// nothing to tell: its cursor moves past what was read
|
|
3948
|
+
if (!items.length) { quiet.push({ sub, through }); noticeWait(board, sub, report); continue; }
|
|
3949
|
+
const digest = digests.get(recipient) ?? { recipient, to: sub.chat_id, cards: new Map() };
|
|
3950
|
+
// a recipient that follows as its agent is mailed at its agent's address, as it subscribed
|
|
3951
|
+
if (agentAt(sub.chat_id) && !agentAt(digest.to)) digest.to = sub.chat_id;
|
|
3952
|
+
const card = digest.cards.get(sub.task_id) ?? { task_id: sub.task_id, task, items: [], subs: [], wake: false };
|
|
3953
|
+
card.items.push(...items);
|
|
3954
|
+
card.subs.push({ sub, through });
|
|
3955
|
+
card.wake ||= wake;
|
|
3956
|
+
digest.cards.set(sub.task_id, card);
|
|
3957
|
+
digests.set(recipient, digest);
|
|
3958
|
+
}
|
|
3959
|
+
// One filing: its cursors move once it is filed (or passed to a person, or already moved), and the recipient's
|
|
3960
|
+
// window state with it; a filing that failed leaves them where they were, and the next pass files it.
|
|
3961
|
+
const file = async (digest, state, { cards, body, subject, kind }) => {
|
|
3962
|
+
const items = cards.flatMap((c) => c.items), subs = cards.flatMap((c) => c.subs), wake = cards.some((c) => c.wake);
|
|
3963
|
+
const key = `board-${kind}-${createHash('sha256').update(`${digest.to}|${items.map((i) => `${i.sub.task_id}:${i.ev.id}`).join(',')}`).digest('hex').slice(0, 24)}`;
|
|
3421
3964
|
try {
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
if (
|
|
3430
|
-
|
|
3431
|
-
const c
|
|
3432
|
-
|
|
3433
|
-
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
3434
|
-
if (text) {
|
|
3435
|
-
// A verified binding may have replaced this audience while liveness
|
|
3436
|
-
// or an earlier filing awaited a door. Re-read before every filing.
|
|
3437
|
-
const currentSub = board.db.prepare(`SELECT last_event_id,delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3438
|
-
const currentRole = json(currentSub?.delivery_metadata, {})?.role ?? 'creator';
|
|
3439
|
-
if (!currentSub || currentRole !== role) break;
|
|
3440
|
-
if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
|
|
3441
|
-
const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
|
|
3442
|
-
// filed under its fixed key: a second pass filing the same notice files nothing new
|
|
3443
|
-
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: numbered(task?.key, task?.number, `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`),
|
|
3444
|
-
eligible: () => view(board, db => {
|
|
3445
|
-
const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3446
|
-
return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
|
|
3447
|
-
}) });
|
|
3448
|
-
if (filed.new) tx(board, (db) => event(db, sub.task_id, 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, message_id: filed.message_id, wake }));
|
|
3449
|
-
if (filed.filed) report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
3965
|
+
const filed = await fileMail(board, digest.to, body, key, { wake, subject,
|
|
3966
|
+
eligible: () => view(board, db => subs.some(({ sub, through }) => {
|
|
3967
|
+
const row = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identityOf(sub));
|
|
3968
|
+
return Boolean(row) && Number(row.last_event_id) < through;
|
|
3969
|
+
})) });
|
|
3970
|
+
tx(board, (db) => {
|
|
3971
|
+
for (const { sub, through } of subs) forward(db, sub, through);
|
|
3972
|
+
if (filed.filed) {
|
|
3973
|
+
state.sent.push(passAt);
|
|
3974
|
+
for (const c of cards) state.cards[c.task_id] = passAt;
|
|
3975
|
+
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 }));
|
|
3450
3976
|
}
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
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 } : {}) });
|
|
3978
|
+
if (filed.new && kind === 'summary') event(db, cards[0].task_id, 'notice_flood_collapsed', { address: digest.to, recipient: digest.recipient, notices: items.length,
|
|
3979
|
+
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
|
+
reason: `more than ${NOTICE_CEILING} notice messages in ${window} s for one recipient: the rest are one summary` });
|
|
3981
|
+
});
|
|
3982
|
+
if (filed.filed) for (const i of items) report.notified.push([i.sub.task_id, i.ev.kind, digest.to]);
|
|
3983
|
+
if (filed.filed && kind === 'summary') (report.notices_collapsed ??= []).push([digest.to, items.length, cards.length]);
|
|
3984
|
+
for (const { sub } of subs) noticeWait(board, sub, report);
|
|
3455
3985
|
} catch (error) {
|
|
3456
|
-
noticeWait(board, sub, report, String(error.message).slice(-200));
|
|
3986
|
+
for (const { sub } of subs) noticeWait(board, sub, report, String(error.message).slice(-200));
|
|
3987
|
+
}
|
|
3988
|
+
};
|
|
3989
|
+
for (const digest of digests.values()) {
|
|
3990
|
+
if (stopping) break;
|
|
3991
|
+
// the recipient's window: the messages filed to it and the cards it was told of in the last `window` seconds
|
|
3992
|
+
const stateKey = `notice_digest:${digest.recipient}`;
|
|
3993
|
+
const stored = json(board.db.prepare('SELECT value FROM supercode_board_state WHERE key = ?').get(stateKey)?.value, null) ?? {};
|
|
3994
|
+
const state = { key: stateKey, sent: [].concat(stored.sent ?? []).map(Number).filter((at) => passAt - at < window),
|
|
3995
|
+
cards: Object.fromEntries(Object.entries(stored.cards ?? {}).filter(([, at]) => passAt - Number(at) < window)) };
|
|
3996
|
+
const cards = [...digest.cards.values()].sort((a, b) => a.items[0].ev.id - b.items[0].ev.id);
|
|
3997
|
+
// a card told within its window waits: what happens on it meanwhile is one message when the window ends
|
|
3998
|
+
const ready = cards.filter((c) => !(c.task_id in state.cards));
|
|
3999
|
+
const hold = (list) => {
|
|
4000
|
+
if (!list.length) return;
|
|
4001
|
+
report.notices_held = (report.notices_held ?? 0) + list.reduce((n, c) => n + c.items.length, 0);
|
|
4002
|
+
const ends = list.map((c) => Number(state.cards[c.task_id] ?? state.sent[0] ?? passAt) + window);
|
|
4003
|
+
report.next_notice_at = Math.min(report.next_notice_at ?? Infinity, ...ends);
|
|
4004
|
+
};
|
|
4005
|
+
hold(cards.filter((c) => c.task_id in state.cards));
|
|
4006
|
+
// the ceiling: at most NOTICE_CEILING messages a window; past it, the rest are one summary message
|
|
4007
|
+
const budget = NOTICE_CEILING - state.sent.length;
|
|
4008
|
+
if (budget <= 0) { hold(ready); continue; }
|
|
4009
|
+
const each = ready.length <= budget ? ready : ready.slice(0, budget - 1);
|
|
4010
|
+
const rest = ready.length <= budget ? [] : ready.slice(budget - 1);
|
|
4011
|
+
for (const c of each) {
|
|
4012
|
+
if (stopping) break;
|
|
4013
|
+
const first = c.items[0], label = numbered(c.task?.key, c.task?.number, c.task?.title || c.task_id, noticeName(c.task));
|
|
4014
|
+
const shown = c.items.slice(0, CARD_DIGEST_ITEMS);
|
|
4015
|
+
const body = c.items.length === 1 ? first.body
|
|
4016
|
+
: `${c.items.length} updates on ${label}:\n\n${shown.map((i) => cutText(i.body, CARD_DIGEST_TEXT)).join('\n\n')}`
|
|
4017
|
+
+ `${c.items.length > shown.length ? `\n\n…and ${c.items.length - shown.length} more` : ''}\n\nThe card: supercode workflow show ${c.task_id} --root ${board.root}`;
|
|
4018
|
+
const subject = c.items.length === 1
|
|
4019
|
+
? numbered(c.task?.key, c.task?.number, `${first.ev.kind.replaceAll('_', ' ')} · run ${first.ev.run_id ?? 'none'} · event ${first.ev.id} · ${c.task?.title || c.task_id}`, noticeName(c.task))
|
|
4020
|
+
: numbered(c.task?.key, c.task?.number, `${c.items.length} updates · ${c.task?.title || c.task_id}`, noticeName(c.task));
|
|
4021
|
+
await file(digest, state, { cards: [c], body, subject, kind: 'notice' });
|
|
3457
4022
|
}
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
4023
|
+
if (rest.length && !stopping) {
|
|
4024
|
+
const count = rest.reduce((n, c) => n + c.items.length, 0);
|
|
4025
|
+
const names = rest.map((c) => numbered(c.task?.key, c.task?.number, c.task_id, noticeName(c.task)));
|
|
4026
|
+
const body = `${count} updates on ${rest.length} card(s): ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}. `
|
|
4027
|
+
+ `Read them on the board: supercode workflow show <card> --root ${board.root}. More than ${NOTICE_CEILING} notice messages in ${window} s would have reached this session, so the rest are this one summary (board event notice_flood_collapsed).`;
|
|
4028
|
+
await file(digest, state, { cards: rest, body, subject: numbered(boardKey(board.root, board.slug), null, `${count} updates on ${rest.length} cards`), kind: 'summary' });
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
if (quiet.length) tx(board, (db) => { for (const { sub, through } of quiet) forward(db, sub, through); });
|
|
4032
|
+
// a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
|
|
4033
|
+
// work is over, and its session may never run again), anyone's when it is archived
|
|
4034
|
+
for (const { sub, role, task, kinds } of read) {
|
|
3461
4035
|
const over = task && (task.stored_as === 'archived' || (role === 'worker' && task.stored_as === 'done'));
|
|
3462
4036
|
if (over) tx(board, (db) => {
|
|
3463
|
-
const durable = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...
|
|
4037
|
+
const durable = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identityOf(sub));
|
|
3464
4038
|
if (!durable) return;
|
|
3465
4039
|
// A page's final event is not the end of the backlog. Check past the persisted cursor in the same
|
|
3466
4040
|
// transaction as retirement, so another page (including the card's completion) is never abandoned.
|
|
@@ -3468,7 +4042,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3468
4042
|
.get(sub.task_id, durable.last_event_id, ...kinds);
|
|
3469
4043
|
const current = getTask(db, sub.task_id);
|
|
3470
4044
|
if (!remaining && current && (current.status === 'archived' || (role === 'worker' && current.status === 'done')))
|
|
3471
|
-
db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...
|
|
4045
|
+
db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identityOf(sub));
|
|
3472
4046
|
});
|
|
3473
4047
|
}
|
|
3474
4048
|
}
|
|
@@ -3647,10 +4221,68 @@ export { boardLabel };
|
|
|
3647
4221
|
|
|
3648
4222
|
// A dispatcher told to stop (its serve's SIGTERM, SIGINT or SIGHUP) starts nothing more in the round it is in: no further
|
|
3649
4223
|
// board, card start, board message or notice. What it leaves unfiled is not a failure; the next dispatcher files it.
|
|
3650
|
-
let stopping = false;
|
|
3651
|
-
|
|
4224
|
+
let stopping = false, park = new AbortController();
|
|
4225
|
+
const parkedError = () => Object.assign(new Error('the board generation parked before this read answered; it is read next round'), { code: 'board_parked' });
|
|
4226
|
+
export function stopDispatching() { stopping = true; park.abort(parkedError()); }
|
|
4227
|
+
/** `work`, or `parkedValue()` as soon as a park lands (the fleet's read, which only observes: the successor's round reads
|
|
4228
|
+
* the fleet again). The park's listener goes with the work, so a long-lived generation keeps none. */
|
|
4229
|
+
function untilParked(work, parkedValue) {
|
|
4230
|
+
if (stopping) return Promise.resolve(parkedValue());
|
|
4231
|
+
const signal = park.signal;
|
|
4232
|
+
return new Promise((resolve, reject) => {
|
|
4233
|
+
const onPark = () => resolve(parkedValue());
|
|
4234
|
+
signal.addEventListener('abort', onPark, { once: true });
|
|
4235
|
+
work.then((value) => { signal.removeEventListener('abort', onPark); resolve(value); }, (error) => { signal.removeEventListener('abort', onPark); reject(error); });
|
|
4236
|
+
});
|
|
4237
|
+
}
|
|
4238
|
+
/**
|
|
4239
|
+
* A parking generation's claims end with its park (cli.mjs, while it still writes). A parked generation stays alive after
|
|
4240
|
+
* its successor serves, and a claim whose process is alive holds until its TTL (store.mjs claimHolds), so the successor
|
|
4241
|
+
* could not close a session or bind an adoption this generation had claimed (VO-3578: run 2408's close, claimed by the
|
|
4242
|
+
* predecessor 61278 at 01:19:26Z, held generation 46402 off it until 01:29:50Z). A close claim is cleared; an adoption
|
|
4243
|
+
* claim is recorded again already expired, which every round reads as a dead round's claim, open again.
|
|
4244
|
+
* Only the rows this process claimed are read (`claimsMade`, by primary key and the card's latest answer), never a scan of
|
|
4245
|
+
* every run's metadata and every adoption event: on bb8821620's first park those two scans read about 5,000 pages the
|
|
4246
|
+
* system no longer held, 5.8 s of the park's 6.27 s (slow.jsonl, pid 78550, 2026-10-24T01:50:12.415Z), and the park is
|
|
4247
|
+
* the window card verbs wait out.
|
|
4248
|
+
*/
|
|
4249
|
+
export function releaseClaims(root) {
|
|
4250
|
+
const lock = claimer();
|
|
4251
|
+
let released = 0;
|
|
4252
|
+
for (const slug of listBoards(root)) {
|
|
4253
|
+
const board = openBoard(root, slug);
|
|
4254
|
+
try {
|
|
4255
|
+
const made = claimsMade.get(board.key);
|
|
4256
|
+
if (!made) continue;
|
|
4257
|
+
released += tx(board, (db) => {
|
|
4258
|
+
let n = 0;
|
|
4259
|
+
for (const runId of made.runs) {
|
|
4260
|
+
const claim = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode?.close_claim;
|
|
4261
|
+
if (claim?.lock === lock) { setRunSession(db, runId, { close_claim: null }); n++; }
|
|
4262
|
+
}
|
|
4263
|
+
for (const id of made.cards) {
|
|
4264
|
+
const last = latestAnswer(db, id);
|
|
4265
|
+
const claim = last?.kind === 'adoption_claimed' ? json(last.payload, {}) ?? {} : null;
|
|
4266
|
+
if (claim?.lock === lock) { event(db, id, 'adoption_claimed', { ...claim, expires: 0 }); n++; }
|
|
4267
|
+
}
|
|
4268
|
+
return n;
|
|
4269
|
+
});
|
|
4270
|
+
claimsMade.delete(board.key);
|
|
4271
|
+
} finally { board.close(); }
|
|
4272
|
+
}
|
|
4273
|
+
return released;
|
|
4274
|
+
}
|
|
4275
|
+
/** The close claims (by run) and adoption claims (by card) this process has written, per board: what its park releases. */
|
|
4276
|
+
const claimsMade = new Map();
|
|
4277
|
+
function noteClaim(board, kind, id) {
|
|
4278
|
+
let made = claimsMade.get(board.key);
|
|
4279
|
+
if (!made) claimsMade.set(board.key, made = { runs: new Set(), cards: new Set() });
|
|
4280
|
+
made[kind].add(id);
|
|
4281
|
+
}
|
|
4282
|
+
/** A round a park ended early: its reports are marked `cut`, so it is never read as a clean round (cli.mjs serving). */
|
|
4283
|
+
const cutRound = (reports) => Object.defineProperty(reports, 'cut', { value: true });
|
|
3652
4284
|
// Only the stable owner's parked generation is resumed; it takes a fresh fencing epoch first.
|
|
3653
|
-
export function resumeDispatching() { stopping = false; }
|
|
4285
|
+
export function resumeDispatching() { stopping = false; park = new AbortController(); }
|
|
3654
4286
|
|
|
3655
4287
|
/** A board file's fingerprint: a corrupt board is skipped until the file changes (Hermes retries it then). */
|
|
3656
4288
|
const fingerprint = (path) => { try { const st = statSync(path); return `${st.size}:${st.mtimeMs}`; } catch { return 'missing'; } };
|
|
@@ -3713,8 +4345,9 @@ function noCodeReviewDue(board, id) {
|
|
|
3713
4345
|
const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
|
|
3714
4346
|
if (!filed && !completedWork(db, id) && !(db.prepare("SELECT 1 FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at IS NOT NULL LIMIT 1").get(id) &&
|
|
3715
4347
|
outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
|
|
3716
|
-
const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
|
|
3717
|
-
|
|
4348
|
+
const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided','review_returned') ORDER BY id DESC LIMIT 1").get(id);
|
|
4349
|
+
// a review the manager returned is due again once the disposition is completed again (a finish after the return)
|
|
4350
|
+
return !last || ['changes_requested','review_voided'].includes(last.kind) || (last.kind === 'review_returned' && Boolean(completedWork(db, id)));
|
|
3718
4351
|
});
|
|
3719
4352
|
}
|
|
3720
4353
|
|
|
@@ -3774,6 +4407,8 @@ function reviewDueKey(board, id, payload) {
|
|
|
3774
4407
|
return `${source}|${closed ?? ''}`;
|
|
3775
4408
|
}
|
|
3776
4409
|
|
|
4410
|
+
const PASSED_WAITS = 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`';
|
|
4411
|
+
|
|
3777
4412
|
/** Review selection shared by rounds and read-only inspection (`dry: true`). */
|
|
3778
4413
|
export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
|
|
3779
4414
|
const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
@@ -3789,13 +4424,26 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3789
4424
|
if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform'))) return false;
|
|
3790
4425
|
const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
|
|
3791
4426
|
if (scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
|
|
3792
|
-
//
|
|
3793
|
-
|
|
4427
|
+
// A person's card needs no review: it closes on that person's completion.
|
|
4428
|
+
if (scope.card.person) return false;
|
|
4429
|
+
// The card's Tasks are its subtasks; an open one holds the review, and says so. A paused one holds nothing.
|
|
4430
|
+
const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status) && !t.paused);
|
|
3794
4431
|
return open.length ? open : true;
|
|
3795
4432
|
};
|
|
3796
4433
|
const ready = view(board, eligible);
|
|
3797
4434
|
if (!ready) { settled.push(id); continue; }
|
|
3798
|
-
if (ready !== true) {
|
|
4435
|
+
if (ready !== true) {
|
|
4436
|
+
settled.push(id);
|
|
4437
|
+
// what holds the final review is said by name, on the round and once on the card each time the set changes
|
|
4438
|
+
const holds = ready.map((t) => `${t.id} "${t.title}"`).join(', ');
|
|
4439
|
+
report.queued.push([id, `review waits on ${ready.length} open task(s): ${holds}`]);
|
|
4440
|
+
if (!dry) tx(board, (db) => {
|
|
4441
|
+
const key = ready.map((t) => t.id).sort().join(',');
|
|
4442
|
+
const last = latestEvent(db, id, ['review_waits_on_tasks']);
|
|
4443
|
+
if (json(last?.payload, {})?.key !== key) event(db, id, 'review_waits_on_tasks', { key, tasks: ready.map((t) => ({ id: t.id, title: t.title, status: t.status })), reason: `the final review waits on ${ready.length} open task(s): ${holds}` });
|
|
4444
|
+
});
|
|
4445
|
+
continue;
|
|
4446
|
+
}
|
|
3799
4447
|
let payload = null;
|
|
3800
4448
|
try {
|
|
3801
4449
|
payload = await openReviewPullRequests(board, id);
|
|
@@ -3804,21 +4452,31 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3804
4452
|
// source (reviewDue).
|
|
3805
4453
|
// An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
|
|
3806
4454
|
if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
|
|
3807
|
-
|
|
3808
|
-
// session's done request and the manager's or creator's `complete`, and the round says that (volter-org
|
|
3809
|
-
// t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
|
|
3810
|
-
if (!payload) { report.queued.push([id, passedReview(board, id) ? 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`' : 'review waits: no open or unjudged merged PR on its declared branches, and no completed disposition']); continue; }
|
|
4455
|
+
if (!payload) { report.queued.push([id, passedReview(board, id) ? PASSED_WAITS : 'review waits: no open or merged PR on its declared branches, and no completed disposition']); continue; }
|
|
3811
4456
|
if (payload.waits) {
|
|
3812
4457
|
report.queued.push([id, `review waits: ${payload.waits}`]);
|
|
3813
4458
|
stalls.push({ id, key: `no_work_branch:${lastReviewEvent(board, id)}`, why: `its review waits: ${payload.waits}` });
|
|
3814
4459
|
payload = null; continue;
|
|
3815
4460
|
}
|
|
4461
|
+
// An arc whose every PR merged at the head its PR review passed (`pr_reviewed`) still gets its final review, of its
|
|
4462
|
+
// Acceptance against them, once that is due (settledReviewDue).
|
|
4463
|
+
const settledOnly = !payload.no_code && !payload.pr.length && !payload.stacked?.length && Boolean(payload.pr_reviewed?.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; }
|
|
3816
4465
|
// A PR of the arc proposed to a branch other than main is refused below, whatever the others' verdicts.
|
|
3817
|
-
if (!payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
3818
|
-
|
|
4466
|
+
if (!settledOnly && !payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
4467
|
+
// An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
|
|
4468
|
+
// session's done request and the manager's or creator's `complete`, and the round says that (volter-org
|
|
4469
|
+
// t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
|
|
4470
|
+
if (passedReview(board, id)) { report.queued.push([id, PASSED_WAITS]); continue; }
|
|
4471
|
+
// 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
|
+
// a PR whose PR review passed at its head waits on its coder's merge at that head, which settles it
|
|
4474
|
+
const passedOpen = payload.pr.filter((url, index) => prReviewedHead(board, id, payload.pr_head[index]));
|
|
4475
|
+
const why = passedOpen.length ? `${passedOpen.join(', ')} passed its PR review and waits on its merge at its reviewed head; every other PR head has a verdict, carries the source tree its verdict judged, or predates it`
|
|
4476
|
+
: `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`;
|
|
3819
4477
|
report.queued.push([id, `review waits: ${why}`]);
|
|
3820
|
-
//
|
|
3821
|
-
|
|
4478
|
+
// A verdict other than a pass that nothing answered waits on an act
|
|
4479
|
+
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.` });
|
|
3822
4480
|
continue;
|
|
3823
4481
|
}
|
|
3824
4482
|
// A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
|
|
@@ -3857,14 +4515,14 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3857
4515
|
const evidence = observed.evidence;
|
|
3858
4516
|
if (!evidence.ready) throw Object.assign(new Error(evidence.reason), { code: evidence.code, refusals: evidence.refusals });
|
|
3859
4517
|
// A dry run names the complete validated review a tick would request and writes nothing.
|
|
3860
|
-
if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
|
|
4518
|
+
if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null, pr_reviewed: payload.pr_reviewed ?? null }); continue; }
|
|
3861
4519
|
const moved = tx(board, db => {
|
|
3862
4520
|
const task = getTask(db, id);
|
|
3863
4521
|
if (eligible(db) !== true) return null;
|
|
3864
4522
|
return withCompletionEvidence(board, id, evidence, () => fire(db, board, entry, id, 'request_review', {
|
|
3865
4523
|
roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, ci: evidence, summary: payload.no_code
|
|
3866
4524
|
? `${payload.no_code.kind === 'disposition' ? 'Scratch operational disposition.' : `No code changed: ${payload.no_code.repository} ${payload.branch} holds no commit beyond ${payload.no_code.base} and no PR was opened from it.`} Review the arc's act from the card: its comments, its tasks' results and what they point at.`
|
|
3867
|
-
: `Source ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}` }
|
|
4525
|
+
: `${payload.pr.length ? `Source ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}` : 'No PR left to review'}${payload.pr_reviewed?.length ? `${payload.pr.length ? '; settled' : ': every PR merged'} at the head its PR review passed: ${payload.pr_reviewed.map((p) => `${p.url} at ${p.head}`).join(', ')}. Review the arc's Acceptance against them.` : ''}` }
|
|
3868
4526
|
}));
|
|
3869
4527
|
});
|
|
3870
4528
|
if (moved) {
|
|
@@ -3884,9 +4542,9 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3884
4542
|
if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
|
|
3885
4543
|
}
|
|
3886
4544
|
|
|
3887
|
-
/** The id of the arc's last review event (requested, changes requested, passed, voided), or 0: a review wait's key
|
|
4545
|
+
/** The id of the arc's last review event (requested, changes requested, passed, voided, returned), or 0: a review wait's key
|
|
3888
4546
|
* carries it, so the wait a new verdict or request leaves is a new wait. */
|
|
3889
|
-
const lastReviewEvent = (board, id) => board.db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided')").get(id)?.id ?? 0;
|
|
4547
|
+
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;
|
|
3890
4548
|
|
|
3891
4549
|
/** A review refusal's wait key: the arc's last review event and the sorted kinds and PRs it refused (ci.mjs refusalOf),
|
|
3892
4550
|
* never a count or a path, so the same refusal is the same wait however its detail or read order changes. */
|
|
@@ -3936,7 +4594,7 @@ function surfaceReviewStalls(board, entry, stalls, settled, report) {
|
|
|
3936
4594
|
for (const stall of plan.tell) {
|
|
3937
4595
|
if (told(db, stall.id) === stall.key) continue;
|
|
3938
4596
|
const card = getTask(db, stall.id);
|
|
3939
|
-
event(db, stall.id, 'board_message', { to, review_wait: stall.key, text: `${numbered(boardKey(board.root, board.slug), card?.number, stall.id)} ${card?.title ?? ''}: ${stall.why}`,
|
|
4597
|
+
event(db, stall.id, 'board_message', { to, review_wait: stall.key, text: `${numbered(boardKey(board.root, board.slug), card?.number, stall.id, noticeName(card))} ${card?.title ?? ''}: ${stall.why}`,
|
|
3940
4598
|
batch: { requester: 'board review waits', title: 'Review waits only an act clears', window_seconds: 0 } }, null);
|
|
3941
4599
|
(report.review_waits_told ??= []).push([stall.id, stall.key]);
|
|
3942
4600
|
}
|
|
@@ -3964,14 +4622,14 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
|
|
|
3964
4622
|
// the controller's delivery pass, run here by the owner (no process of its own), never two at once for a home; the
|
|
3965
4623
|
// round never waits for it
|
|
3966
4624
|
if (deliveries.has(root)) return false;
|
|
3967
|
-
const pass = ownCall(() => deliver({ root, slugs, controller: true })).then((reports) => {
|
|
4625
|
+
const pass = ownCall(() => deliver({ root, slugs, controller: true }), 'a delivery pass').then((reports) => {
|
|
3968
4626
|
for (const r of reports) {
|
|
3969
4627
|
if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
|
|
3970
4628
|
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a delivery for ${task} to ${to} waits: ${why}`);
|
|
3971
4629
|
for (const [task, to] of r.notices_resumed ?? []) log('info', `board ${r.board}: notices for ${task} to ${to} resumed`);
|
|
3972
4630
|
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(', ')}`);
|
|
3973
4631
|
}
|
|
3974
|
-
}, (error) => log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
|
|
4632
|
+
}, (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));
|
|
3975
4633
|
deliveries.set(root, pass);
|
|
3976
4634
|
return true;
|
|
3977
4635
|
}
|
|
@@ -4016,7 +4674,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
4016
4674
|
board.label = boardLabel(root, slug);
|
|
4017
4675
|
setActor(boardAddress(board.label));
|
|
4018
4676
|
let mark = Date.now();
|
|
4019
|
-
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
4677
|
+
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; roundStep(`${slug} ${name}`); };
|
|
4020
4678
|
// board mail and notices are filed in their receivers' mailboxes (local writes); the mailbox delivers them
|
|
4021
4679
|
// a controller's pass reads the board's switch before each phase and each effect: switched off meanwhile, what had
|
|
4022
4680
|
// not begun is left (an operator's `workflow deliver` delivers as asked)
|
|
@@ -4024,7 +4682,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
4024
4682
|
if (on()) { await fileBoardMail(board, report); phase('mail'); }
|
|
4025
4683
|
if (on()) { report.effects = await runEffects(board, [], { pending: true, eligible: controller ? on : null }); phase('effects'); }
|
|
4026
4684
|
if (on()) { await notify(board, fleet, report, entry); phase('notify'); }
|
|
4027
|
-
if (on()) { await synchronizeAgents(board, report); phase('agents'); }
|
|
4685
|
+
if (on()) { await synchronizeAgents(board, report, fleet); phase('agents'); }
|
|
4028
4686
|
report.effects_failed = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NOT NULL').get().n);
|
|
4029
4687
|
report.effects_waiting = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NULL').get().n);
|
|
4030
4688
|
} catch (error) { report.error = error.message; }
|
|
@@ -4034,6 +4692,40 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
4034
4692
|
return out;
|
|
4035
4693
|
}
|
|
4036
4694
|
|
|
4695
|
+
/**
|
|
4696
|
+
* The round's close pass alone (closeRequested, its supervise phase's last act), for the boards in `slugs`: the owner
|
|
4697
|
+
* runs it each interval for every board whose dispatching is off, so an ended run's session closes as it does with
|
|
4698
|
+
* dispatching on. Switching dispatching off stops launches, not closes: VO-3578's reviewer run r2612 ended 12 s after
|
|
4699
|
+
* its board was switched off, and its pane stayed open. Nothing else of a round runs here: no mailbox, adoption,
|
|
4700
|
+
* reconnection, launch, supervision, review, start, effect or delivery. Answers one report per board.
|
|
4701
|
+
*/
|
|
4702
|
+
export async function closeEnded({ root, slugs, settings }) {
|
|
4703
|
+
fleetRoot = root;
|
|
4704
|
+
if (stopping || !slugs.length || boardCopyOf({ root })) return [];
|
|
4705
|
+
// a session's native discovery is read once a pass, as once a round (readNative)
|
|
4706
|
+
discoveries.clear();
|
|
4707
|
+
// the fleet reading settles a launch that never bound a pane; one unread leaves that close waiting, as a round's does
|
|
4708
|
+
const fleet = await untilParked(observeFleet(), () => null).catch((error) => ({ ok: false, error: error.message }));
|
|
4709
|
+
if (!fleet) return [];
|
|
4710
|
+
const out = [];
|
|
4711
|
+
for (const slug of slugs) {
|
|
4712
|
+
if (stopping) break;
|
|
4713
|
+
const report = { board: slug, closed: [], undelivered: [] };
|
|
4714
|
+
const path = boardPath(root, slug);
|
|
4715
|
+
if (existsSync(`${path}.corrupt-seen`) && readFileSync(`${path}.corrupt-seen`, 'utf8').trim() === fingerprint(path)) continue;
|
|
4716
|
+
let board;
|
|
4717
|
+
try {
|
|
4718
|
+
board = openBoard(root, slug);
|
|
4719
|
+
board.label = boardLabel(root, slug);
|
|
4720
|
+
setActor(boardAddress(board.label));
|
|
4721
|
+
await closeRequested(board, report, { ttl: settings.ttl, fleet });
|
|
4722
|
+
} catch (error) { report.error = error.message; }
|
|
4723
|
+
finally { board?.close(); }
|
|
4724
|
+
out.push(report);
|
|
4725
|
+
}
|
|
4726
|
+
return out;
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4037
4729
|
/**
|
|
4038
4730
|
* One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
|
|
4039
4731
|
* `only` limits starting to one card (a create that answers whether its card started). No dispatcher file lock: two
|
|
@@ -4052,7 +4744,16 @@ export async function deliver({ root, slugs = null, controller = false }) {
|
|
|
4052
4744
|
* effect and delivers nothing: it records effects (`queueEffects`) and the delivery pass (`deliver`) carries them out.
|
|
4053
4745
|
* Answers one report per board.
|
|
4054
4746
|
*/
|
|
4055
|
-
|
|
4747
|
+
// A round's progress, said to whoever asked for it (`tick`'s `progress`; the board generation's first round, which its
|
|
4748
|
+
// owner bounds by progress, never by the round's length): each phase a board's round ends and each door call that
|
|
4749
|
+
// settles, answered or not. A round slow only because another machine answers slowly is progressing; one in which
|
|
4750
|
+
// nothing settles is not. It is the round's own: a card verb this process answers meanwhile says nothing of it.
|
|
4751
|
+
const roundProgress = new AsyncLocalStorage();
|
|
4752
|
+
const roundStep = (step) => { try { roundProgress.getStore()?.(step); } catch { /* the round goes on unsaid */ } };
|
|
4753
|
+
export function tick({ progress = null, ...options }) {
|
|
4754
|
+
return roundProgress.run(progress, () => boardRound(options));
|
|
4755
|
+
}
|
|
4756
|
+
async function boardRound({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
|
|
4056
4757
|
fleetRoot = root;
|
|
4057
4758
|
discoveries.clear();
|
|
4058
4759
|
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
@@ -4061,13 +4762,21 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
4061
4762
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
4062
4763
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
4063
4764
|
const fleetAt = Date.now();
|
|
4064
|
-
const fleet = needFleet ? await observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
4765
|
+
const fleet = needFleet ? await untilParked(observeFleet(), () => null) : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
4065
4766
|
const fleetMs = Date.now() - fleetAt;
|
|
4767
|
+
roundStep('the fleet read');
|
|
4768
|
+
// A park (stopDispatching) ends the round at its next step, not at its end: the successor's first round does the rest,
|
|
4769
|
+
// and every act re-reads its basis in its own transaction, so a round cut between steps leaves nothing half done. A
|
|
4770
|
+
// round that only read has nothing to keep; one that moved cards keeps the effects those moves asked for (queueEffects
|
|
4771
|
+
// below runs whatever was skipped). A park that waited out a whole round (11-15 s of other machines' doors) missed the
|
|
4772
|
+
// owner's 8 s wait for 'parked', and the handover rolled back (VO-3578, pin V, 21:53Z).
|
|
4773
|
+
if (stopping) return cutRound([]);
|
|
4066
4774
|
await readMemoryPressure();
|
|
4067
4775
|
const boards = slugs ?? listBoards(root);
|
|
4068
4776
|
// what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so): every
|
|
4069
4777
|
// board, including one whose dispatching is off, whose live workers still hold their places
|
|
4070
4778
|
const held = dispatch ? await homeHoldings(root, listBoards(root), lanes, fleet, entry) : [];
|
|
4779
|
+
if (stopping) return cutRound([]);
|
|
4071
4780
|
const out = [];
|
|
4072
4781
|
for (const slug of boards) {
|
|
4073
4782
|
if (stopping) break;
|
|
@@ -4090,17 +4799,20 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
4090
4799
|
let mark = Date.now();
|
|
4091
4800
|
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
4092
4801
|
|
|
4093
|
-
|
|
4094
|
-
|
|
4802
|
+
// each step after a park is skipped (above); the effects of what moved are still recorded (queueEffects)
|
|
4803
|
+
const going = () => !stopping;
|
|
4804
|
+
if (going()) await readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
4805
|
+
if (going()) { await refreshRelationAnswers(board); settleStateRequestAnswers(board, entry, report); } phase('answers');
|
|
4095
4806
|
const effects = [];
|
|
4096
|
-
await completeAdoptions(board, fleet, settings, report
|
|
4807
|
+
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');
|
|
4097
4808
|
report.unsettled = [];
|
|
4098
4809
|
report.promoted = recomputeReady(board, effects, report.unsettled);
|
|
4099
4810
|
tx(board,db=>auditEndedRuns(db,board,entry));
|
|
4100
|
-
if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only, fleet });
|
|
4101
|
-
if (dispatch &&
|
|
4811
|
+
if (dispatch && going()) await activatePullRequestReviews(board, entry, report, effects, { only, fleet });
|
|
4812
|
+
if (dispatch && going()) await startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
|
|
4102
4813
|
phase('start');
|
|
4103
|
-
if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
|
|
4814
|
+
if (dispatch && !only && !report.paused && going()) await observeIdle(board, fleet, settings, report, entry, effects);
|
|
4815
|
+
if (dispatch && !only && !report.paused && going()) retellBriefs(board, entry, report);
|
|
4104
4816
|
// what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
|
|
4105
4817
|
queueEffects(board, effects);
|
|
4106
4818
|
phase('idle');
|
|
@@ -4119,7 +4831,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
4119
4831
|
}
|
|
4120
4832
|
out.push(report);
|
|
4121
4833
|
}
|
|
4122
|
-
if (
|
|
4834
|
+
if (stopping) cutRound(out);
|
|
4835
|
+
if (deployment && !out.cut && out.every(report => !report.error)) {
|
|
4123
4836
|
const receipt = await retainWorkflowTick(root, deployment);
|
|
4124
4837
|
if (receipt && out[0]) out[0].deployment = receipt;
|
|
4125
4838
|
}
|