@volter/supercode-orchestrator 0.5.228 → 0.5.229
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/cli.mjs
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { registerWorkService, closeWorkLedger } from '@volter/supercode-harness-sdk/work-ledger';
|
|
2
|
+
import { registerWorkOperations, measureWork } from "@volter/supercode-harness-sdk/work-ledger";
|
|
3
|
+
registerWorkOperations([{"kind":"door","operation":"board.command"}]);
|
|
4
|
+
import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
|
|
5
|
+
const spawnSync = (...args) => measureSyncChild(() => workLedgerNativeSpawnSync(...args));
|
|
1
6
|
// `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
|
|
2
7
|
// format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
|
|
3
8
|
// output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`set`, `adopt`, `detach`, `close`,
|
|
@@ -7,11 +12,12 @@
|
|
|
7
12
|
import { machineCall, closeDoors, onLocalReopen } from './doors.mjs';
|
|
8
13
|
import { createHash } from 'node:crypto';
|
|
9
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
10
|
-
import { homeOf, parse } from './args.mjs';
|
|
15
|
+
import { assertWorkflowVerb, homeOf, parse } from './args.mjs';
|
|
11
16
|
import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from '@volter/teams/board-owner';
|
|
12
|
-
import { current, isCallersCall, ownCall, within } from './call.mjs';
|
|
17
|
+
import { current, isCallersCall, ownCall, refuseUnreadManager, requestReceiptEnvelope, within } from './call.mjs';
|
|
13
18
|
import { cardState, holdDescription } from './card-state.mjs';
|
|
14
|
-
import {
|
|
19
|
+
import { breakGlassLog, breakGlassReason, guardRefusal, recordBreakGlass } from '@volter/teams/break-glass';
|
|
20
|
+
import { detachSession, sessionOperation } from './custody.mjs';
|
|
15
21
|
import { reclaimSpace, taskStatusOf, trimDue, trimPublications } from './published.mjs';
|
|
16
22
|
import { executionOf, readingContext } from './session-work.mjs';
|
|
17
23
|
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
@@ -22,46 +28,45 @@ import { openOwnerDoor } from './owner-door.mjs';
|
|
|
22
28
|
import { inBoardGeneration, generationWork, admitGeneration, grantGenerationWriter, parkGeneration, stopGeneration, tellOwner, admittedWork, GENERATION_STOPPED } from './generation.mjs';
|
|
23
29
|
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
24
30
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
25
|
-
import {
|
|
26
|
-
import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
|
|
31
|
+
import { spawnSync as workLedgerNativeSpawnSync } from 'node:child_process';
|
|
27
32
|
import { holdLock } from './lock-holder.mjs';
|
|
28
33
|
import { takeEpoch } from './fence.mjs';
|
|
29
|
-
import {
|
|
34
|
+
import { dispatcherDiagnostics, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
|
|
30
35
|
import { boardTool } from './tools.mjs';
|
|
31
36
|
import { arcSources, assertSourceReceipt } from './ci.mjs';
|
|
32
|
-
import { noteStep, recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
|
|
37
|
+
import { noteStep, recordSlow, takeSteps, timed } from '@volter/supercode-harness-sdk/slow-log';
|
|
33
38
|
// read by name, so an older harness SDK without them still loads: the owner then keeps no per-verb or loop lines
|
|
34
39
|
import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
|
|
35
40
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
36
41
|
import { fileURLToPath } from 'node:url';
|
|
37
42
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
43
|
+
import { prepareBoardRestingAdmission } from './resting-admission.mjs';
|
|
44
|
+
import { resolveNativeSessionHome } from '@volter/supercode-interchange';
|
|
38
45
|
import { storageContract } from './storage-contract.mjs';
|
|
39
|
-
import { boardKey, cardRef, actorOf, CALLER_RUN_ADDRESS, CALLER_RUN_AGENT, SESSION_CURRENT_RUNS, SESSION_RUNS, sessionIndexed, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardCopyOf, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
|
|
40
|
-
import { isLocalWorkspace } from './workspace.mjs';
|
|
46
|
+
import { boardKey, cardRef, actorOf, CALLER_RUN_ADDRESS, CALLER_RUN_AGENT, SESSION_CURRENT_RUNS, SESSION_RUNS, sessionIndexed, heldConfigWatched, heldDocumentsChanged, ownBoardWrite, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardCopyOf, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
|
|
47
|
+
import { cardWorktrees, isLocalWorkspace } from './workspace.mjs';
|
|
41
48
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
42
49
|
import * as engine from './engine.mjs';
|
|
43
50
|
import { recordCardAgentChanges } from './agent-declarations.mjs';
|
|
44
51
|
|
|
45
|
-
// A mailbox read through
|
|
46
|
-
//
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
}
|
|
52
|
+
// A mailbox read through this machine's daemon's mail door (in this process, over its held channel), as `supercode
|
|
53
|
+
// message waiting|expired --json` reads it: its rows, or none when the daemon does not answer.
|
|
54
|
+
const mailRows = async (verb) => {
|
|
55
|
+
try { const { rows } = await machineCall(null, `harness.v1.mail.${verb}`, {}, { timeoutMs: 15_000 }); return Array.isArray(rows) ? rows : []; }
|
|
56
|
+
catch { return []; }
|
|
57
|
+
};
|
|
51
58
|
|
|
52
|
-
/** The messages on this machine the mailbox still has to deliver (`
|
|
59
|
+
/** The messages on this machine the mailbox still has to deliver (`harness.v1.mail.waiting`), by message id. */
|
|
53
60
|
async function mailWaiting() {
|
|
54
|
-
|
|
55
|
-
try { return new Map((JSON.parse(stdout) ?? []).map((mail) => [mail.id, mail])); } catch { return new Map(); }
|
|
61
|
+
return new Map((await mailRows('waiting')).map((mail) => [mail.id, mail]));
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
|
-
* The messages on this machine whose delivery the mailbox gave up on (`
|
|
65
|
+
* The messages on this machine whose delivery the mailbox gave up on (`harness.v1.mail.expired`), by message id:
|
|
60
66
|
* filed by the board, never delivered within the mailbox's bound, kept unread. Empty when the mailbox does not answer.
|
|
61
67
|
*/
|
|
62
68
|
async function mailExpiries() {
|
|
63
|
-
|
|
64
|
-
try { return new Map((JSON.parse(stdout) ?? []).map((expiry) => [expiry.id, expiry])); } catch { return new Map(); }
|
|
69
|
+
return new Map((await mailRows('expired')).map((expiry) => [expiry.id, expiry]));
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -76,7 +81,7 @@ async function boardPublishing(root) {
|
|
|
76
81
|
const board = listed?.boards?.find((b) => b.root === here);
|
|
77
82
|
return board ? { ...board, refused: listed.refused ?? null } : null;
|
|
78
83
|
}
|
|
79
|
-
import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, closeEnded, lanesOf, localMachine, observeFleet, paused, plan, startCard, releaseClaims, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
84
|
+
import { answerQuestion, assertHeadlessBinding, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, closeEnded, lanesOf, localMachine, normalMachine, observeFleet, paused, plan, startCard, releaseClaims, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
80
85
|
import { watchBoard } from './wake.mjs';
|
|
81
86
|
import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
|
|
82
87
|
import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
|
|
@@ -86,7 +91,7 @@ import { drainAgentCredentials, fileFleetNotice } from './dispatch.mjs';
|
|
|
86
91
|
import { streamBoardPublications } from './event-stream.mjs';
|
|
87
92
|
import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor, watchWorkflows } from './workflow.mjs';
|
|
88
93
|
import { evaluate } from './expr.mjs';
|
|
89
|
-
import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
|
|
94
|
+
import { agentDoor,agentScope,activeAgentAssignments,cardRoleAssignment,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
|
|
90
95
|
|
|
91
96
|
// Text a verb stores, read from --file/--body-file PATH (`-` for stdin) when given: a shell never expands what it
|
|
92
97
|
// never sees, as it does `$(...)` and `$VAR` in an argument.
|
|
@@ -119,7 +124,7 @@ The board's verbs, with their flags:
|
|
|
119
124
|
set <id> <assignee|machine|model|every|reviewer> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
120
125
|
reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
|
|
121
126
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
122
|
-
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch... | --source none] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
127
|
+
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch... | --source none] [--pr-disposition merge|release-only [--release-target TARGET] --reason TEXT | --release-target TARGET --released-artifact SHA --release-evidence POINTER [--attest-release] --reason TEXT] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
123
128
|
attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
|
|
124
129
|
complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
|
|
125
130
|
person-done <id> --person person:<principal> (--result <answer> | --result-file PATH|-) [--workplace-task <Task id>]
|
|
@@ -127,9 +132,11 @@ The board's verbs, with their flags:
|
|
|
127
132
|
integration reports it from the home; the board checks it names the card's person. Never a session's verb.
|
|
128
133
|
finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
|
|
129
134
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
130
|
-
pause <id> --reason <why>
|
|
135
|
+
pause <id> --reason <why> --until <UTC time|2h> resume <id>
|
|
136
|
+
backlog <id> --reason <why> [--pause-revision <generation|legacy event id>] plan <id>
|
|
137
|
+
cancel <id> --reason <why>
|
|
131
138
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
132
|
-
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> [--plan] --pr URL --pr-head SHA [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... --reason <why> (voids the verdict, or restarts a review run that has none) return-review <id> --reason <why> (manager: back to its coder, no verdict) reopen <id>... [--reason]
|
|
139
|
+
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> [--plan] --pr URL --pr-head SHA [--supersedes QUEUED_EVENT] [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... --reason <why> (voids the verdict, or restarts a review run that has none; returns it when the arc re-declared its sources since its request) return-review <id> --reason <why> (manager: back to its coder, no verdict) reopen <id>... [--reason]
|
|
133
140
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
134
141
|
specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
135
142
|
decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
|
|
@@ -176,6 +183,7 @@ And:
|
|
|
176
183
|
comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; each declaration adds to the earlier ones, on any run, and --pr none alone clears them. This records evidence; only the board starts its review.
|
|
177
184
|
comment <arc> TEXT --source owner/repo:branch...: the arc's implementer or the manager declares every repository and branch the arc changes, on the arc itself; its review discovers PRs from them and accepts --pr declarations in them, with no task registered there. Each declaration adds to the earlier ones, and --source none alone clears them.
|
|
178
185
|
request-review <id> --pr URL --pr-head SHA [--summary "<what the PR does>"]: hand one ready PR to its PR review at its pushed head, whatever other PRs the arc has; merge it at that head once the review passes. A request naming its PR is a PR review.
|
|
186
|
+
request-review <id> --supersedes QUEUED_EVENT --pr URL --pr-head SHA: explicitly replace that card's pending queued request after fresh admission; preserve its receipt and FIFO position, without changing the active review.
|
|
179
187
|
request-review <id> --plan --pr URL --pr-head SHA: a big arc's plan review, of the proposed ADR's PR at its pushed head, before any code. The arc's final review (of every PR not merged at a PR-reviewed head, with its Acceptance) is the board's.
|
|
180
188
|
request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
|
|
181
189
|
request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
|
|
@@ -185,6 +193,11 @@ And:
|
|
|
185
193
|
heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. A side session
|
|
186
194
|
(adopt --side) works the arc's subtasks and comments on the arc; the arc's run and state stay its main session's. The home's
|
|
187
195
|
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
196
|
+
A card's session writes outside its own card (another card, a board verb, --all) only on purpose: with
|
|
197
|
+
--break-glass "<why it is needed>" it passes that guard (and the manager check adopt, release and detach repeat) and is
|
|
198
|
+
recorded in the machine's break-glass journal with the session, what it wrote and the reason, followed there by its
|
|
199
|
+
outcome when it was refused or failed; without one nothing is written and the board says why the write is guarded.
|
|
200
|
+
The workflow's own rules for who may make each transition still apply.
|
|
188
201
|
`;
|
|
189
202
|
|
|
190
203
|
|
|
@@ -340,7 +353,23 @@ async function requester(args) {
|
|
|
340
353
|
|
|
341
354
|
const tagsOf = (card) => { const tags = Array.isArray(card.tags) ? card.tags : json(card.tags, null); return Array.isArray(tags) ? tags : card.tenant ? [card.tenant] : []; };
|
|
342
355
|
|
|
343
|
-
const say = (args, human, value) => {
|
|
356
|
+
const say = (args, human, value) => {
|
|
357
|
+
const requests = current().routedRequests?.splice(0) ?? [];
|
|
358
|
+
const accepted = current().acceptedRequests?.splice(0) ?? [];
|
|
359
|
+
const queued = current().queuedRequests?.splice(0) ?? [];
|
|
360
|
+
const unqueued = current().unqueuedReviews?.splice(0) ?? [];
|
|
361
|
+
const receipts = requestReceiptEnvelope({ requests, accepted, queued, unqueued });
|
|
362
|
+
if (receipts) {
|
|
363
|
+
value = receipts;
|
|
364
|
+
human = [
|
|
365
|
+
...requests.map(request => `Retained ${request.requested} for ${request.manager} (request ${request.message_id}).`),
|
|
366
|
+
...queued.map(request => `${request.already ? 'Already queued' : 'Queued'} review of ${request.pr ?? request.requested ?? 'the PR'} after the current review.${request.pending ? ` Waiting: ${request.reason}` : ''}`),
|
|
367
|
+
...accepted.map(request => `Recorded ${request.requested}${request.from ? `; card remains ${request.to}` : ''}: ${request.reason}`),
|
|
368
|
+
...unqueued.map(request => request.reason),
|
|
369
|
+
].join('\n');
|
|
370
|
+
}
|
|
371
|
+
current().out(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`);
|
|
372
|
+
};
|
|
344
373
|
|
|
345
374
|
// Hermes's own output lines, so a reader of `hermes kanban list` reads this one
|
|
346
375
|
const ICONS = { todo: '◻', ready: '▶', running: '●', scheduled: '⏱', blocked: '⊘', paused: '⏸', done: '✓', archived: '—' };
|
|
@@ -351,7 +380,8 @@ const ts = (t) => {
|
|
|
351
380
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
352
381
|
};
|
|
353
382
|
// a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
|
|
354
|
-
const
|
|
383
|
+
const hasExecution = (t) => ['running', 'review'].includes(t.status) || (t.execution?.state === 'awaiting_session' && t.execution.since != null);
|
|
384
|
+
const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.number ? t.readable_id : null).padEnd(32)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : hasExecution(t) ? ` [session ${t.execution?.state ?? 'unknown'}${t.execution?.reason ? ` — ${t.execution.reason}` : ''}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
355
385
|
// `key` is the card's board's (store.mjs boardKey); `ref` the card as a person names it (`VO-856`)
|
|
356
386
|
const stateOptions = root => ({ nativeHolds: Boolean(workflowFor(root).workflow.flags?.paused) });
|
|
357
387
|
const cardDict = (raw, key, options) => { const t = cardState(raw, options); return ({
|
|
@@ -362,19 +392,10 @@ const cardDict = (raw, key, options) => { const t = cardState(raw, options); ret
|
|
|
362
392
|
session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
|
|
363
393
|
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null, detached: t.detached ?? null,
|
|
364
394
|
reviewer: t.reviewer ?? null,
|
|
365
|
-
paused: t.paused, blocked_by: t.blocked_by,
|
|
395
|
+
paused: t.paused, backlog: t.backlog ?? null, blocked_by: t.blocked_by,
|
|
366
396
|
}); };
|
|
367
397
|
|
|
368
398
|
/** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
|
|
369
|
-
// a moment the caller names: an ISO time, or a duration from now (30m, 2h); epoch seconds, or null
|
|
370
|
-
function untilOf(v) {
|
|
371
|
-
if (v === undefined || v === null || v === '' || v === true) return null;
|
|
372
|
-
if (/^\d+(?:\.\d+)?[smhd]?$/i.test(String(v).trim())) return now() + duration(v);
|
|
373
|
-
const t = Date.parse(String(v));
|
|
374
|
-
if (Number.isNaN(t)) throw new Error(`malformed --until ${v} (expected an ISO time such as 2026-09-29T04:35Z, or a duration such as 2h)`);
|
|
375
|
-
return Math.floor(t / 1000);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
399
|
function duration(v) {
|
|
379
400
|
if (v === undefined || v === null || v === '') return null;
|
|
380
401
|
const s = String(v).trim().toLowerCase();
|
|
@@ -429,13 +450,130 @@ function resolveCardNumbers(verb, rest, args, withBoard, keyOf, exists) {
|
|
|
429
450
|
}));
|
|
430
451
|
}
|
|
431
452
|
|
|
432
|
-
|
|
453
|
+
const isServiceVerb = verb => ['serve', 'daemon'].includes(verb);
|
|
454
|
+
const localServiceDoorError = () => Object.assign(new Error('ERR_BOARD_SERVICE_LOCAL_ONLY: a resident board starts through its local stable owner door'), { code: 'ERR_BOARD_SERVICE_LOCAL_ONLY' });
|
|
455
|
+
|
|
456
|
+
/** Admit only the scoped session guard for a new owner. The caller keeps its outcome open through the actual
|
|
457
|
+
* owner lifetime and cleanup; admitting a service alone never completes a break-glass act.
|
|
458
|
+
* An incomplete request is retained and returns null without admitting a service. */
|
|
459
|
+
export async function admitBoardService(argv) {
|
|
460
|
+
let args;
|
|
461
|
+
try {
|
|
462
|
+
args = parse(argv);
|
|
463
|
+
assertSourceReceipt(args);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
await retainUnappliedRequest(argv, error);
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
const [verb, ...rest] = args._;
|
|
469
|
+
if (!isServiceVerb(verb)) throw localServiceDoorError();
|
|
470
|
+
const root = homeOf(args);
|
|
471
|
+
let glass = null;
|
|
472
|
+
const admission = { root, args, finish(failure, code) {
|
|
473
|
+
if (glass) followBreakGlass(glass, failure, code);
|
|
474
|
+
} };
|
|
475
|
+
try {
|
|
476
|
+
const sid = callerSession();
|
|
477
|
+
if (!sid) return admission;
|
|
478
|
+
const named = boardOf(root, args), slug = isView(root, named) ? DEFAULT_BOARD : named;
|
|
479
|
+
if (!boardExists(root, slug)) return admission;
|
|
480
|
+
const board = openBoard(root, slug);
|
|
481
|
+
try {
|
|
482
|
+
const guard = scopedSessionGuard(board, { root, slug, verb, rest, args, sid });
|
|
483
|
+
if (!guard) return admission;
|
|
484
|
+
const entry = await loadWorkflow(root);
|
|
485
|
+
if (managerSession(sid, entry.params, false)) return admission;
|
|
486
|
+
// Explicit break glass authorizes this scope crossing without pretending an unread agent main was resolved.
|
|
487
|
+
const explicit = typeof args['break-glass'] === 'string' && args['break-glass'].trim();
|
|
488
|
+
if (!explicit) {
|
|
489
|
+
const managers = Array.isArray(entry.params?.managers) ? entry.params.managers : [];
|
|
490
|
+
if (managers.some(address => /^sc:([^:\s]+):agent:([^:\s]+)$/.test(String(address)))) {
|
|
491
|
+
await ownerStep('machine identity', () => knowLocalMachine());
|
|
492
|
+
await ownerStep('manager main sessions', () => readAgentMains(root));
|
|
493
|
+
if (managerSession(sid, entry.params)) return admission;
|
|
494
|
+
}
|
|
495
|
+
breakGlassOrRefuse(guard.refused, guard.scope);
|
|
496
|
+
}
|
|
497
|
+
breakGlassReason(explicit, {});
|
|
498
|
+
current().recorded = board.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address ?? null;
|
|
499
|
+
if (!current().recorded && !boardCaller()) await ownerStep('machine identity', () => knowLocalMachine());
|
|
500
|
+
setActor(callerAddress());
|
|
501
|
+
glass = breakGlassOrRefuse(guard.refused, guard.scope);
|
|
502
|
+
return admission;
|
|
503
|
+
} finally { board.close(); }
|
|
504
|
+
} catch (error) {
|
|
505
|
+
admission.finish(error, 1);
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. A card's
|
|
511
|
+
* session's write that broke glass (breakGlassOrRefuse) is followed in the journal by what became of it, unless it
|
|
512
|
+
* was simply done: the journal never shows an act as done that was refused or failed after its entry was written. */
|
|
433
513
|
export async function runVerb(argv) {
|
|
514
|
+
const context = parse(argv, { contextOnly: true });
|
|
515
|
+
if (isServiceVerb(context._[0]) && !context.help) throw localServiceDoorError();
|
|
516
|
+
const call = current();
|
|
517
|
+
call.glass = null;
|
|
518
|
+
call.routedRequests = [];
|
|
519
|
+
call.acceptedRequests = [];
|
|
520
|
+
call.queuedRequests = [];
|
|
521
|
+
call.unqueuedReviews = [];
|
|
522
|
+
call.boardCommand = null;
|
|
523
|
+
let code, failure = null;
|
|
524
|
+
try {
|
|
525
|
+
code = await runVerbBody(argv);
|
|
526
|
+
if (call.routedRequests.length || call.acceptedRequests.length || call.queuedRequests.length || call.unqueuedReviews.length) say(parse(argv, { contextOnly: true }), '', null);
|
|
527
|
+
return code;
|
|
528
|
+
} catch (error) {
|
|
529
|
+
failure = error;
|
|
530
|
+
code = await retainUnappliedRequest(argv, error);
|
|
531
|
+
return code;
|
|
532
|
+
}
|
|
533
|
+
finally { if (call.glass) followBreakGlass(call.glass, failure, code); }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Ordinary verbs and local service admission retain incomplete input through
|
|
537
|
+
// one owner, while the caller still holds this home's writer authority.
|
|
538
|
+
async function retainUnappliedRequest(argv, error) {
|
|
539
|
+
const call = current();
|
|
540
|
+
if (call.readOnly) throw Object.assign(error, { code: READ_BY_OWNER });
|
|
541
|
+
// an unread manager identity is answered to its caller as itself, never handed to the manager
|
|
542
|
+
if (error.code === 'manager_identity_unavailable') throw error;
|
|
543
|
+
// Earlier doors (argument parsing, caller scope, attachments and starts) can
|
|
544
|
+
// discover a request they cannot apply before the workflow sees its event.
|
|
545
|
+
// Record incomplete detail locally or route an explicitly reserved act.
|
|
546
|
+
// No restricted action is retried here and no manager role is inferred.
|
|
547
|
+
const args = parse(argv, { contextOnly: true });
|
|
548
|
+
const root = call.boardCommand?.root ?? homeOf({ ...args, root: args.root ?? call.facts.SUPERCODE_AGENT_HOME });
|
|
549
|
+
await loadWorkflow(root);
|
|
550
|
+
const named = call.boardCommand?.slug ?? boardOf(root, args);
|
|
551
|
+
const slug = isView(root, named) ? DEFAULT_BOARD : named;
|
|
552
|
+
const board = openBoard(root, slug);
|
|
553
|
+
try {
|
|
554
|
+
if (!actorOf()) setActor(callerAddress() ?? 'unresolved board caller');
|
|
555
|
+
let id = NO_CARD_WORDS.has(args._[0]) ? null : call.boardCommand?.id ?? args._[1] ?? null;
|
|
556
|
+
if (id && !engine.getTask(board.db, id)) id = engine.cardByReadableId(board.db, id) ?? null;
|
|
557
|
+
const ownerRule = error.ownerRule || error.code === 'forbidden'
|
|
558
|
+
|| /only the manager|only .*manager.*creator|scoped to|outside.*own card|person's completion.*never by a session/i.test(error.message ?? '');
|
|
559
|
+
if (!ownerRule && (error.errcode != null || /^(?:E[A-Z_]+|ERR_)/.test(error.code ?? '') || error instanceof TypeError || error instanceof ReferenceError)) throw error;
|
|
560
|
+
const receipt = await (ownerRule ? engine.routeRequest : engine.acceptRequest)(board, { id, name: args._[0] ?? 'workflow', payload: { argv }, reason: String(error.message ?? error) });
|
|
561
|
+
say(args, '', null);
|
|
562
|
+
return receipt.ok === false ? 1 : 0;
|
|
563
|
+
} catch (handoffError) {
|
|
564
|
+
if (handoffError === error || handoffError.code === 'manager_identity_unavailable') throw handoffError;
|
|
565
|
+
// A failed store or mailbox enqueue is a technical failure, never a
|
|
566
|
+
// successful handoff. Keep both exact paths visible to the caller.
|
|
567
|
+
throw new Error(`${error.message}; board request could not be retained: ${handoffError.message}`, { cause: handoffError });
|
|
568
|
+
} finally { board.close(); }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function runVerbBody(argv) {
|
|
434
572
|
const args = parse(argv);
|
|
435
573
|
assertSourceReceipt(args);
|
|
436
|
-
await ownerStep('machine identity', () => knowLocalMachine());
|
|
437
574
|
const [verb, ...rest] = args._;
|
|
438
575
|
if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
|
|
576
|
+
await ownerStep('machine identity', () => knowLocalMachine());
|
|
439
577
|
const root = homeOf(args);
|
|
440
578
|
if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
|
|
441
579
|
if (verb === 'listeners') {
|
|
@@ -456,26 +594,29 @@ export async function runVerb(argv) {
|
|
|
456
594
|
}
|
|
457
595
|
const viewTag = isView(root, named) ? named : null;
|
|
458
596
|
const slug = viewTag ? DEFAULT_BOARD : named;
|
|
597
|
+
current().boardCommand = { root, slug, id: rest[0] ?? null };
|
|
459
598
|
const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
|
|
460
599
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
461
600
|
const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
|
|
462
601
|
// the board's key (store.mjs boardKey), read when a card is named or shown by its number
|
|
463
602
|
const keyOf = () => boardKey(root, slug);
|
|
464
|
-
resolveCardNumbers(verb, rest, args, withBoard, keyOf, () => boardExists(root, slug));
|
|
465
|
-
const id = rest[0];
|
|
466
|
-
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
467
|
-
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
468
603
|
|
|
469
604
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
470
605
|
const listing = ['list','ls'].includes(verb);
|
|
471
606
|
const readingDispatching = verb === 'dispatching' && rest.length === 0;
|
|
472
|
-
const reading = READS.has(verb) || readingDispatching;
|
|
607
|
+
const reading = READS.has(verb) || readingDispatching || (verb === 'dispatch' && args['dry-run']);
|
|
473
608
|
// a listing on the owner's read door reads the workflow the owner holds (owner-door.mjs), and names no session: it
|
|
474
609
|
// records nothing, and `--mine` is the caller's profile
|
|
475
610
|
const readDoor = Boolean(current().readOnly);
|
|
476
611
|
if (!readDoor) await ownerStep('workflow', () => loadWorkflow(root)).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
477
|
-
|
|
478
|
-
|
|
612
|
+
assertWorkflowVerb(args, workflowFor(root, { optional: true })?.workflow);
|
|
613
|
+
resolveCardNumbers(verb, rest, args, withBoard, keyOf, () => boardExists(root, slug));
|
|
614
|
+
const id = rest[0];
|
|
615
|
+
current().boardCommand.id = id ?? null;
|
|
616
|
+
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
617
|
+
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
618
|
+
// Read-only answers grant no transition. Init still admits the initial board write.
|
|
619
|
+
if (!reading || verb === 'init') await ownerStep('manager main sessions', () => readAgentMains(root));
|
|
479
620
|
const sid = listing && (!args.mine || readDoor) ? null : callerSession();
|
|
480
621
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
481
622
|
current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
|
|
@@ -483,19 +624,16 @@ export async function runVerb(argv) {
|
|
|
483
624
|
const recordedAgent = sid && boardExists(root, slug)
|
|
484
625
|
? await ownerStep('committed native agent ownership', () => withBoard((b) => recordedRunAgent(b.db, sid, callerAddress()))) : null;
|
|
485
626
|
setPrincipal(await ownerStep('caller principal', () => callerPrincipal(root, sid, { teams: !sid && !reading, recordedAgent })));
|
|
486
|
-
// the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
|
|
487
|
-
if (!current().said.has(`dispatcher:${slug}`)) { current().said.add(`dispatcher:${slug}`); for (const line of dispatcherNotice(slug)) current().err(`${line}\n`); }
|
|
488
627
|
if (sid && !reading && boardExists(root, slug)) {
|
|
489
628
|
// binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
|
|
490
629
|
// never writes)
|
|
491
630
|
await withBoardAsync(async (b) => {
|
|
492
631
|
if (isCallersCall()) await reconcileCallerLaunch(b, sid);
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
authorize(b.db, verb, rest, args, own, { copyServe });
|
|
632
|
+
// Working a card does not take away a workflow-declared manager's board authority. Adoption keeps identity;
|
|
633
|
+
// the ordinary transition checks below still admit each act against that manager role.
|
|
634
|
+
if (rolesFor(b.db, '', sid, workflowFor(root).params).includes('manager')) return;
|
|
635
|
+
const guard = scopedSessionGuard(b, { root, slug, verb, rest, args, sid });
|
|
636
|
+
if (guard) current().glass = breakGlassOrRefuse(guard.refused, guard.scope);
|
|
499
637
|
});
|
|
500
638
|
}
|
|
501
639
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
@@ -604,9 +742,10 @@ export async function runVerb(argv) {
|
|
|
604
742
|
})), true);
|
|
605
743
|
await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
|
|
606
744
|
let answer = { id: created.id, existing: created.existing };
|
|
745
|
+
if (current().glass) current().glass.created = created.id;
|
|
607
746
|
if (args.adopt) {
|
|
608
|
-
withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
609
|
-
|
|
747
|
+
withBoard(b => { if (!current().glass && !rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
748
|
+
await withBoardAsync(b => requestAdoption(b, created.id, args.adopt));
|
|
610
749
|
answer = { ...answer, adoption_requested: args.adopt };
|
|
611
750
|
}
|
|
612
751
|
// Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
|
|
@@ -637,8 +776,9 @@ export async function runVerb(argv) {
|
|
|
637
776
|
|
|
638
777
|
case 'list': case 'ls': {
|
|
639
778
|
const assignee = args.assignee ?? (args.mine ? callerName() : null);
|
|
779
|
+
if (args.backlog != null && !['true', 'false'].includes(String(args.backlog))) throw new Error('--backlog takes true or false');
|
|
640
780
|
const cards = await withBoardAsync(b => listCardsInTurns(b.db, { ...stateOptions(root), detailed: Boolean(args.json), status: args.status, assignee, machine: args.machine, includeArchived: args.archived || args.all, includeSubtasks: args.subtasks || args.all,
|
|
641
|
-
tenant: args.tenant, session: args.session, workflowTemplateId: args['workflow-template-id'], stepKey: args['step-key'], tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])] }));
|
|
781
|
+
backlog: args.backlog == null ? null : args.backlog === 'true', tenant: args.tenant, session: args.session, workflowTemplateId: args['workflow-template-id'], stepKey: args['step-key'], tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])] }));
|
|
642
782
|
const sorters = {
|
|
643
783
|
assignee: (a, b) => String(a.assignee ?? '').localeCompare(String(b.assignee ?? '')), created: (a, b) => a.created_at - b.created_at,
|
|
644
784
|
'created-desc': (a, b) => b.created_at - a.created_at, priority: (a, b) => a.priority - b.priority, 'priority-desc': (a, b) => b.priority - a.priority,
|
|
@@ -665,7 +805,8 @@ export async function runVerb(argv) {
|
|
|
665
805
|
need(1, 'a card id');
|
|
666
806
|
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
667
807
|
const card = withBoard((b) => {
|
|
668
|
-
const got = engine.showCard(b.db, id, stateOptions(root));
|
|
808
|
+
const got = engine.showCard(b.db, id, { ...stateOptions(root), review: engine.reviewsCard(b.db, id, { sessionId: sid, runId: workerRun(b.db, id, sid) }) });
|
|
809
|
+
if (got.review_request) return got;
|
|
669
810
|
got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
|
|
670
811
|
got.task.execution = executionOf(engine.currentRun(b.db, id), readingContext(b.db, got.task.id, got.task.current_run_id));
|
|
671
812
|
got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
|
|
@@ -679,6 +820,11 @@ export async function runVerb(argv) {
|
|
|
679
820
|
: timeOf(b.db, got.task);
|
|
680
821
|
return got;
|
|
681
822
|
});
|
|
823
|
+
if (card.review_request) {
|
|
824
|
+
if (args.json) say(args, '', card);
|
|
825
|
+
else current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id, { review: true })))}\n`);
|
|
826
|
+
return 0;
|
|
827
|
+
}
|
|
682
828
|
const t = cardState({ ...card.task, stage: card.stage }, stateOptions(root));
|
|
683
829
|
const latest = [...card.runs].reverse().find((r) => r.summary)?.summary ?? null;
|
|
684
830
|
if (args.json) {
|
|
@@ -694,7 +840,7 @@ export async function runVerb(argv) {
|
|
|
694
840
|
if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
|
|
695
841
|
if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
|
|
696
842
|
// what the card's session is doing as the board last read it, beside the lane (ADR 0024)
|
|
697
|
-
else if (
|
|
843
|
+
else if (hasExecution(t)) out.push(` session: ${card.task.execution?.state ?? 'unknown'}${card.task.execution?.reason ? ` — ${card.task.execution.reason}` : ''}`);
|
|
698
844
|
// the question the card's session shows on its pane, while it shows (VO-3395), with the door that answers it
|
|
699
845
|
const asked = card.runs.find((r) => r.id === t.current_run_id)?.metadata?.supercode?.question;
|
|
700
846
|
if (asked?.options?.length) {
|
|
@@ -781,7 +927,7 @@ export async function runVerb(argv) {
|
|
|
781
927
|
return 0;
|
|
782
928
|
}
|
|
783
929
|
|
|
784
|
-
case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
|
|
930
|
+
case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id, { review: engine.reviewsCard(db, id, { sessionId: sid, runId: workerRun(db, id, sid) }) })))}\n`); return 0;
|
|
785
931
|
|
|
786
932
|
case 'runs': {
|
|
787
933
|
need(1, 'a card id');
|
|
@@ -810,7 +956,7 @@ export async function runVerb(argv) {
|
|
|
810
956
|
if(!task)throw new Error(`no card ${id}`);
|
|
811
957
|
withBoard(b=>{if(!rolesFor(b.db,id,sid,workflowFor(root).params).includes('manager'))throw new Error('Only the manager moves a card assignment between agents.');});
|
|
812
958
|
const role=args.role??'implementer';
|
|
813
|
-
const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.
|
|
959
|
+
const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.id===cardRoleAssignment(id,role));
|
|
814
960
|
if(!assignment)throw new Error('declare the card role before reassigning its assignment');
|
|
815
961
|
const changed=await reassignAgent(root,scope.org,assignment.id,args.agent,{context});
|
|
816
962
|
withBoard(b=>recordCardAgentChanges(b,id));
|
|
@@ -842,10 +988,10 @@ export async function runVerb(argv) {
|
|
|
842
988
|
return 0;
|
|
843
989
|
}
|
|
844
990
|
case 'reclaim': need(1, 'a card id'); await withBoardAsync((b) => engine.reclaim(b, id, args.reason ?? (rest.slice(1).join(' ') || null))); say(args, `Reclaimed ${id}`, { ok: true }); return 0;
|
|
845
|
-
case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null, { until:
|
|
991
|
+
case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null, { until: args.until ?? null })); say(args, `Heartbeat recorded for ${id}${args.until != null ? `; wait recorded until ${args.until}` : ''}`, { ok: true, ...(args.until != null ? { until: args.until } : {}) }); return 0;
|
|
846
992
|
|
|
847
993
|
case 'link': need(2, 'a parent and a child'); await withBoardAsync((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
|
|
848
|
-
case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `
|
|
994
|
+
case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `Link ${rest[0]} -> ${rest[1]} is already absent`, { ok: true, already: !gone }); return 0; }
|
|
849
995
|
|
|
850
996
|
// C4: a card's ordered tags; Hermes's tenant follows the first, and a board view selects by tag
|
|
851
997
|
case 'tag': case 'untag': {
|
|
@@ -870,7 +1016,7 @@ export async function runVerb(argv) {
|
|
|
870
1016
|
if (args.file !== undefined && rest.length > 1) throw new Error(`comment ${id} takes its text from --file or from words, not both; unexpected: ${JSON.stringify(rest.slice(1).join(' ').slice(0, 120))}. Nothing was recorded.`);
|
|
871
1017
|
let text = textFrom(args.file, rest.slice(1).join(' '));
|
|
872
1018
|
if (args['max-len']) text = text.slice(0, Number(args['max-len']));
|
|
873
|
-
const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text, { pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, source: args.source ?? null }));
|
|
1019
|
+
const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text, { pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, source: args.source ?? null, disposition: args['pr-disposition'] ?? null, release_target: args['release-target'] ?? null, reason: args.reason ?? null, readback: !args['pr-disposition'] && (args['release-target'] || args['released-artifact'] || args['release-evidence'] || args['attest-release']) ? { target: args['release-target'], artifact: args['released-artifact'], evidence: args['release-evidence'], attest: Boolean(args['attest-release']) } : null }));
|
|
874
1020
|
say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
|
|
875
1021
|
}
|
|
876
1022
|
case 'attach': {
|
|
@@ -880,7 +1026,7 @@ export async function runVerb(argv) {
|
|
|
880
1026
|
withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
|
|
881
1027
|
const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
|
|
882
1028
|
const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
|
|
883
|
-
add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
|
|
1029
|
+
add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerAddress() ?? callerName() })) });
|
|
884
1030
|
say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
|
|
885
1031
|
return 0;
|
|
886
1032
|
}
|
|
@@ -892,7 +1038,7 @@ export async function runVerb(argv) {
|
|
|
892
1038
|
let target = join(dir, name);
|
|
893
1039
|
for (let n = 1; existsSync(target); n += 1) target = join(dir, `${n}-${name}`);
|
|
894
1040
|
copyFileSync(source, target);
|
|
895
|
-
const n = withBoard((b) => engine.addAttachment(b, id, { file: { path: target, size: statSync(target).size }, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() }));
|
|
1041
|
+
const n = withBoard((b) => engine.addAttachment(b, id, { file: { path: target, size: statSync(target).size }, name, contentType: args['content-type'] ?? null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
896
1042
|
say(args, `Attached ${name} to ${id} (#${n})`, { ok: true, id: n, path: target }); return 0;
|
|
897
1043
|
}
|
|
898
1044
|
case 'attachments': {
|
|
@@ -928,17 +1074,7 @@ export async function runVerb(argv) {
|
|
|
928
1074
|
case 'person-done': {
|
|
929
1075
|
need(1, 'a card id');
|
|
930
1076
|
if (sid) throw new Error('A person\'s completion is reported by the Workplace integration from the home, never by a session; nothing was done.');
|
|
931
|
-
|
|
932
|
-
// reporter does not send it again. Any other failure is the reporter's to retry; the same report again is `already`.
|
|
933
|
-
let done;
|
|
934
|
-
try { done = await withBoardAsync((b) => engine.personDone(b, id, { person: args.person ?? null, result: textFrom(args['result-file'], args.result), task: args['workplace-task'] ?? null })); }
|
|
935
|
-
catch (error) {
|
|
936
|
-
// the card's refusal is final, as dispatch.mjs takes a moved card's (a transition that does not apply there), and
|
|
937
|
-
// so is an answer refused as a credential dump: sent again, it is refused again
|
|
938
|
-
if (!error.refusal && !error.noTransition && !/does not apply|there is for/.test(String(error.message)) && error.code !== 'credential_dump') throw error;
|
|
939
|
-
say(args, `${id} refused ${args.person ?? 'the person'}'s completion: ${error.message}`, { ok: false, card: id, person: args.person ?? null, refused: error.message });
|
|
940
|
-
return 2;
|
|
941
|
-
}
|
|
1077
|
+
const done = await withBoardAsync((b) => engine.personDone(b, id, { person: args.person ?? null, result: textFrom(args['result-file'], args.result), task: args['workplace-task'] ?? null }));
|
|
942
1078
|
say(args, done.already ? `${id} was already completed by ${args.person}` : `Completed ${id} for ${args.person}`, { ok: true, card: id, person: args.person, already: done.already });
|
|
943
1079
|
return 0;
|
|
944
1080
|
}
|
|
@@ -964,42 +1100,56 @@ export async function runVerb(argv) {
|
|
|
964
1100
|
case 'unblock': {
|
|
965
1101
|
need(1, 'a card id');
|
|
966
1102
|
const results = [];
|
|
967
|
-
|
|
1103
|
+
const applied = [];
|
|
1104
|
+
for (const t of ids()) {
|
|
1105
|
+
const result = await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? args['waiting-on'] ?? null }));
|
|
1106
|
+
results.push([t, result.to]);
|
|
1107
|
+
if (!result.routed && !result.accepted) applied.push(t);
|
|
1108
|
+
}
|
|
968
1109
|
// Hermes records the reason as a comment on each card it unblocks
|
|
969
|
-
if (args.reason) for (const
|
|
1110
|
+
if (args.reason) for (const t of applied) await withBoardAsync((b) => engine.comment(b, t, callerAddress() ?? callerName(), `UNBLOCK: ${args.reason}`));
|
|
970
1111
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
971
1112
|
}
|
|
972
|
-
case 'request-review':
|
|
1113
|
+
case 'request-review': {
|
|
1114
|
+
need(1, 'a card id');
|
|
1115
|
+
const result = await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, plan: Boolean(args.plan), force: Boolean(args.force), supersedes: args.supersedes ?? null, expectedRunId: workerRun(b.db, id, sid) }));
|
|
1116
|
+
say(args, `${id} → review`, result);
|
|
1117
|
+
return result.ok === false ? 1 : 0;
|
|
1118
|
+
}
|
|
973
1119
|
case 'request-changes': {
|
|
974
|
-
need(
|
|
1120
|
+
need(1, 'a card id');
|
|
975
1121
|
// each finding is filed as a task on the arc first: the arc's review is due again once every one is closed (ci.mjs answeredChanges)
|
|
976
1122
|
const findings = [].concat(args.finding ?? []).map((f) => String(f).trim()).filter(Boolean);
|
|
977
1123
|
const filed = [];
|
|
978
1124
|
for (const finding of findings) filed.push((await withBoardAsync((b) => engine.createCard(b, { title: finding, subtaskOf: id }))).id);
|
|
979
|
-
await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null }));
|
|
1125
|
+
await withBoardAsync((b) => engine.requestChanges(b, id, { reason: args.reason ?? (rest.slice(1).join(' ') || 'none given'), reviewer: args.reviewer ?? null }));
|
|
980
1126
|
say(args, `${id} sent back for changes${filed.length ? `; findings filed as ${filed.join(', ')}` : ''}`, { ok: true, findings: filed }); return 0;
|
|
981
1127
|
}
|
|
982
1128
|
case 'reopen': {
|
|
983
1129
|
// a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
|
|
984
1130
|
need(1, 'a card id');
|
|
985
1131
|
const results = [];
|
|
986
|
-
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopen(b, t, { author: callerName(), reason: args.reason ?? null }))).to]);
|
|
1132
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopen(b, t, { author: callerAddress() ?? callerName(), reason: args.reason ?? null }))).to]);
|
|
987
1133
|
say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
988
1134
|
}
|
|
989
1135
|
case 'reopen-review': {
|
|
990
1136
|
need(1, 'a card id');
|
|
991
1137
|
const results = [];
|
|
992
|
-
for (const t of ids()) { const moved = await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null })); results.push([t, moved.to, moved.restarted_run ?? null]); }
|
|
1138
|
+
for (const t of ids()) { const moved = await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null })); results.push([t, moved.to, moved.restarted_run ?? null, moved.returned_run ?? null]); }
|
|
993
1139
|
// a review still running with no verdict is restarted, not voided: its run ends, its reviewer session is closed and
|
|
994
|
-
// the board's next review run resumes that same session with the round's brief
|
|
995
|
-
|
|
1140
|
+
// the board's next review run resumes that same session with the round's brief; an arc re-declared since its request
|
|
1141
|
+
// is returned to its coder instead. A PR with a newer queued request returns only its obsolete execution; the
|
|
1142
|
+
// retained queue owns that PR's next review through ordinary admission.
|
|
1143
|
+
say(args, results.map(([t, s, run, returned, queued]) => queued ? `Returned ${t}'s obsolete review: run ${returned} ended with no verdict. Newest queued request ${queued} for that PR retains its receipt and position for ordinary review admission.`
|
|
1144
|
+
: returned ? `Returned ${t}'s review to its coder (${s}): run ${returned} ended with no verdict, since the arc re-declared its sources after its review request. Its next review is of the declared set, requested only when the ordinary review rules make one due (for example a declared PR at a head no verdict has judged, or the final review once every PR is merged at the head its PR review passed). No verdict was voided.`
|
|
1145
|
+
: run ? `Restarted ${t}'s review: run ${run} ended with no verdict and its reviewer session was closed; the board's next review run resumes it (${s}). No verdict was voided.` : `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
996
1146
|
}
|
|
997
1147
|
case 'return-review': {
|
|
998
1148
|
// the manager's door out of review: a card there by mistake goes back to its coder, its review run ending as returned
|
|
999
1149
|
need(1, 'a card id');
|
|
1000
1150
|
const reason = args.reason ?? (rest.slice(1).join(' ') || null);
|
|
1001
1151
|
const moved = await withBoardAsync((b) => engine.returnReview(b, id, { reason }));
|
|
1002
|
-
say(args, `Returned ${id} from review → ${moved.to}: its review run ended as returned with no verdict; its coder continues, and the board reviews again once a pushed PR head changes.`, { ok: true, ...moved }); return 0;
|
|
1152
|
+
say(args, `Returned ${id} from review → ${moved.to}: its review run ended as returned with no verdict; its coder continues, and the board reviews again once a pushed PR head changes or the arc re-declares its sources (comment --pr, --pr none, --source, --source none).`, { ok: true, ...moved }); return 0;
|
|
1003
1153
|
}
|
|
1004
1154
|
case 'promote': {
|
|
1005
1155
|
need(1, 'a card id');
|
|
@@ -1012,14 +1162,17 @@ export async function runVerb(argv) {
|
|
|
1012
1162
|
const ok = ['todo', 'blocked'].includes(card.task.status) && (!open.length || args.force);
|
|
1013
1163
|
return { id: t, ok, reason: ok ? null : !['todo', 'blocked'].includes(card.task.status) ? `${t} is ${card.task.status}` : `parents not done: ${open.join(', ')}` };
|
|
1014
1164
|
}));
|
|
1015
|
-
say(args, verdicts.map((v) => `${v.ok ? 'would promote' : 'would
|
|
1165
|
+
say(args, verdicts.map((v) => `${v.ok ? 'would promote' : 'would record request'} ${v.id}${v.reason ? `: ${v.reason}` : ''}`).join('\n'), verdicts);
|
|
1016
1166
|
return 0;
|
|
1017
1167
|
}
|
|
1018
|
-
for (const t of targets) await withBoardAsync((b) => engine.promote(b, t, { actor: callerName(), reason, force: args.force }));
|
|
1168
|
+
for (const t of targets) await withBoardAsync((b) => engine.promote(b, t, { actor: callerAddress() ?? callerName(), reason, force: args.force }));
|
|
1019
1169
|
say(args, targets.map((t) => `Promoted ${t} → ready`).join('\n'), { ok: true }); return 0;
|
|
1020
1170
|
}
|
|
1021
1171
|
case 'archive': {
|
|
1022
|
-
for (const t of rest)
|
|
1172
|
+
for (const t of rest) {
|
|
1173
|
+
const result = await withBoardAsync((b) => engine.archive(b, t, { reason: args.reason }));
|
|
1174
|
+
if (result.routed || result.accepted) { say(args, '', null); return 0; }
|
|
1175
|
+
}
|
|
1023
1176
|
const purged = [];
|
|
1024
1177
|
for (const t of args.rm ?? []) {
|
|
1025
1178
|
withBoard((b) => tx(b, (db) => {
|
|
@@ -1049,6 +1202,7 @@ export async function runVerb(argv) {
|
|
|
1049
1202
|
results.push({ id: t, children: await withBoardAsync((b) => engine.decompose(b, t, children, { author: args.author ?? callerAddress() ?? callerName() })) });
|
|
1050
1203
|
} else {
|
|
1051
1204
|
const session = await withBoardAsync((b) => startCard(b, t, { lanes, settings, roles: rolesFor(b.db, t, sid, workflowFor(root).params) }));
|
|
1205
|
+
if (session?.routed) { results.push({ id: t, ...session }); continue; }
|
|
1052
1206
|
results.push({ id: t, session: session.address, mode: session.mode });
|
|
1053
1207
|
}
|
|
1054
1208
|
}
|
|
@@ -1064,7 +1218,7 @@ export async function runVerb(argv) {
|
|
|
1064
1218
|
const order = ['info', 'warning', 'error', 'critical'];
|
|
1065
1219
|
const found = withBoard((b) => {
|
|
1066
1220
|
const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, stateOptions(root));
|
|
1067
|
-
return [...(args.task ? [] : engine.diagnosePublishing(publishing).map((d) => ({ task: '(board)', title_of_task: null, ...d }))
|
|
1221
|
+
return [...[...dispatcherDiagnostics(slug), ...(args.task ? [] : engine.diagnosePublishing(publishing))].map((d) => ({ task: '(board)', title_of_task: null, ...d })),
|
|
1068
1222
|
...cards.flatMap((t) => engine.diagnose(b.db, t, { ...stateOptions(root), failureLimit: settings.failureLimit, fleet, launchWindowSeconds: settings.launchWindow, publishing, expiredMail, waitingMail }).map((d) => ({ task: t.id, title_of_task: t.title, ...d })))]
|
|
1069
1223
|
.filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
|
|
1070
1224
|
});
|
|
@@ -1219,22 +1373,35 @@ export async function runVerb(argv) {
|
|
|
1219
1373
|
else for (const r of reports) current().out(`${r.board}: ${r.error ? `error: ${r.error}` : `${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good, ${r.notified.length} notified`}\n`);
|
|
1220
1374
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
1221
1375
|
}
|
|
1222
|
-
case 'daemon': case 'serve': return serve(root, args);
|
|
1223
|
-
|
|
1224
1376
|
case 'gc': {
|
|
1225
1377
|
const eventDays = Number(args['event-retention-days'] ?? 30);
|
|
1226
1378
|
const logDays = Number(args['log-retention-days'] ?? 30);
|
|
1227
1379
|
let removedWs = 0;
|
|
1228
1380
|
const scratchRoot = slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces');
|
|
1229
|
-
|
|
1381
|
+
// archived cards, and closed cards whose worktree was last kept (its merge may have landed since); a worktree
|
|
1382
|
+
// last removed, or last refused (a folder the board never removes), is not read again
|
|
1383
|
+
const archived = withBoard((b) => {
|
|
1384
|
+
const last = b.db.prepare("SELECT kind, json_extract(payload, '$.refused') AS refused FROM task_events WHERE task_id = ? AND kind IN ('worktree_kept', 'worktree_removed') AND json_extract(payload,'$.path')=? ORDER BY id DESC LIMIT 1");
|
|
1385
|
+
return b.db.prepare(`SELECT t.id,t.status,t.workspace_kind,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
1386
|
+
WHERE t.status = 'archived' OR (t.workspace_kind = 'worktree' AND t.status IN ('done', 'cancelled'))`).all().filter((t) => {
|
|
1387
|
+
if (t.workspace_kind !== 'worktree') return t.status === 'archived';
|
|
1388
|
+
return cardWorktrees(b.db, t).some(allocation => {
|
|
1389
|
+
const seen = last.get(t.id, allocation.path);
|
|
1390
|
+
return !seen || seen.kind === 'worktree_kept' && !seen.refused;
|
|
1391
|
+
});
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
const unread = [];
|
|
1230
1395
|
for (const t of archived) {
|
|
1231
|
-
if (
|
|
1232
|
-
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1235
|
-
if (
|
|
1396
|
+
if (t.workspace_kind === 'worktree') {
|
|
1397
|
+
// a closed card's worktree goes as the delivery pass retires it: only when its branch is merged, its tree clean
|
|
1398
|
+
// and its sessions closed, else kept and described on the card (engine cleanupWorkspace; the branch stays); a
|
|
1399
|
+
// card in a person's custody is theirs, and one card's failure leaves the rest of gc to run
|
|
1400
|
+
try { if (await withBoardAsync((b) => sessionOperation(b, { taskId: t.id, kind: 'workflow gc workspace cleanup' }, () => engine.cleanupWorkspace(b, t.id)))) removedWs += 1; }
|
|
1401
|
+
catch (error) { unread.push(`${t.id}: ${String(error?.message ?? error).slice(0, 200)}`); }
|
|
1236
1402
|
continue;
|
|
1237
1403
|
}
|
|
1404
|
+
if (!isLocalWorkspace(t) || t.status !== 'archived') continue;
|
|
1238
1405
|
if (t.workspace_kind !== 'scratch') continue;
|
|
1239
1406
|
const dir = resolve(t.workspace_path || join(scratchRoot, t.id));
|
|
1240
1407
|
if (!dir.startsWith(`${resolve(scratchRoot)}/`) || !existsSync(dir)) continue;
|
|
@@ -1245,7 +1412,7 @@ export async function runVerb(argv) {
|
|
|
1245
1412
|
let logs = 0;
|
|
1246
1413
|
const logDir = logsDir(root, slug);
|
|
1247
1414
|
if (existsSync(logDir)) for (const f of readdirSync(logDir)) { const p = join(logDir, f); if (statSync(p).isFile() && statSync(p).mtimeMs < Date.now() - logDays * 86400_000) { unlinkSync(p); logs += 1; } }
|
|
1248
|
-
say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
|
|
1415
|
+
say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed${unread.length ? `; worktrees not retired now: ${unread.join('; ')}` : ''}`, { workspaces: removedWs, events, logs, workspaces_kept: unread });
|
|
1249
1416
|
return 0;
|
|
1250
1417
|
}
|
|
1251
1418
|
case 'compact': {
|
|
@@ -1282,7 +1449,7 @@ export async function runVerb(argv) {
|
|
|
1282
1449
|
for (let tries = 0; tries < 5 && sized(`${path}-wal`) > 0; tries++) { vacuum('PRAGMA wal_checkpoint(TRUNCATE)'); if (sized(`${path}-wal`) > 0) await new Promise((done) => setTimeout(done, 1000)); }
|
|
1283
1450
|
return { trimmed, reclaimed_bytes: before.bytes - onDisk(), wal_bytes: sized(`${path}-wal`), pages_before: before.pages, free_before: before.free, pages_after: pages(), free_after: free(), seconds: Math.round((performance.now() - began) / 100) / 10 };
|
|
1284
1451
|
});
|
|
1285
|
-
if (report.refused)
|
|
1452
|
+
if (report.refused) throw new Error(`${report.trimmed} older change(s) cut; rebuild needs the manager to resolve: ${report.refused}`);
|
|
1286
1453
|
say(args, `${report.trimmed} older change(s) cut; the store and its WAL are ${Math.round(report.reclaimed_bytes / 1048576)} MB smaller on disk after ${report.seconds} s (${report.pages_before} pages, ${report.free_before} free, to ${report.pages_after}, ${report.free_after} free; WAL ${report.wal_bytes} bytes)`, report);
|
|
1287
1454
|
return 0;
|
|
1288
1455
|
}
|
|
@@ -1313,40 +1480,61 @@ export async function runVerb(argv) {
|
|
|
1313
1480
|
// A headless worker records its harness session on its run (kanban-worker.mjs), through the owner like every board
|
|
1314
1481
|
// write. Only the run the owner launched for this worker: the launch's secret (in the worker's environment alone,
|
|
1315
1482
|
// its hash kept on the run), its card and its run are checked at the write, against the card's current, unended run.
|
|
1316
|
-
need(
|
|
1483
|
+
need(1, 'a run id');
|
|
1317
1484
|
// the secret is the worker's own environment (SUPERCODE_RUN_LAUNCH, sent as a caller fact), never an argument
|
|
1318
|
-
const runId = Number(id), harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
|
|
1319
|
-
if (!Number.isSafeInteger(runId) || !SAFE_SESSION.test(
|
|
1485
|
+
const runId = Number(id), sessionId = rest[1] ?? null, harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
|
|
1486
|
+
if (!Number.isSafeInteger(runId) || (sessionId !== null && !SAFE_SESSION.test(sessionId)) || typeof harness !== 'string' || !SAFE_SESSION.test(harness) || !launch || !card || (sessionId === null && args['native-home'] == null)) throw new Error('run-session takes a run id, a session id or --native-home, --harness and --task, from a worker the owner launched');
|
|
1487
|
+
let nativeHome;
|
|
1488
|
+
if (args['native-home'] != null) {
|
|
1489
|
+
nativeHome = json(args['native-home'], null);
|
|
1490
|
+
const env = nativeHome?.env, homes = nativeHome?.homes;
|
|
1491
|
+
if (!env || typeof env !== 'object' || Array.isArray(env) || !homes || typeof homes !== 'object' || Array.isArray(homes)
|
|
1492
|
+
|| Object.values(env).some(value => value !== null && typeof value !== 'string')) throw new Error('native-home must name the native store and its path selectors');
|
|
1493
|
+
const canonical = await resolveNativeSessionHome(harness, env, process.cwd());
|
|
1494
|
+
const same = (left, right) => Object.keys(left).length === Object.keys(right).length && Object.entries(left).every(([key, value]) => Object.hasOwn(right, key) && right[key] === value);
|
|
1495
|
+
if (!canonical || !same(homes, canonical.homes) || !same(env, canonical.env) || Object.keys(nativeHome).length !== 2) throw new Error('native-home must contain only the absolute native store and its canonical path selectors');
|
|
1496
|
+
nativeHome = canonical;
|
|
1497
|
+
}
|
|
1320
1498
|
const refused = withBoard((b) => tx(b, (db) => {
|
|
1321
1499
|
const row = db.prepare('SELECT r.task_id, r.ended_at, r.metadata, t.current_run_id FROM task_runs r JOIN tasks t ON t.id = r.task_id WHERE r.id = ?').get(runId);
|
|
1322
1500
|
const saved = json(row?.metadata, {})?.supercode ?? {};
|
|
1323
1501
|
if (!row || row.task_id !== card || row.ended_at != null || Number(row.current_run_id) !== runId || saved.surface !== 'headless' || !saved.launch_secret
|
|
1324
|
-
|| saved.launch_secret !== createHash('sha256').update(launch).digest('hex') || (saved.session_id && saved.session_id !==
|
|
1325
|
-
|
|
1502
|
+
|| saved.launch_secret !== createHash('sha256').update(launch).digest('hex') || (sessionId && saved.session_id && saved.session_id !== sessionId)) return `run ${runId} is not a launch this worker holds; nothing was recorded`;
|
|
1503
|
+
if (nativeHome && saved.native_home && JSON.stringify(saved.native_home) !== JSON.stringify(nativeHome)) return `run ${runId} already names another native home; nothing was recorded`;
|
|
1504
|
+
assertHeadlessBinding(b, card, runId, harness, sessionId ?? saved.session_id ?? null);
|
|
1505
|
+
engine.setRunSession(db, runId, { ...saved, ...(sessionId ? { session_id: sessionId, launch_pending: false } : {}), harness, ...(nativeHome ? { native_home: nativeHome } : {}) });
|
|
1326
1506
|
return null;
|
|
1327
1507
|
}));
|
|
1328
1508
|
if (refused) throw new Error(refused);
|
|
1329
|
-
say(args, `run ${runId}: session ${
|
|
1509
|
+
say(args, `run ${runId}: ${sessionId ? `session ${sessionId}` : 'native home recorded'}`, { ok: true }); return 0;
|
|
1330
1510
|
}
|
|
1331
1511
|
case 'dispatching': {
|
|
1332
1512
|
// the board's dispatching switch (its owner's controller): `dispatching` says it, `dispatching on|off` sets it
|
|
1333
1513
|
if (id && !['on', 'off'].includes(id)) throw new Error('dispatching takes on or off');
|
|
1334
1514
|
// switching it is the manager's: a caller outside any session, or a session the workflow's params.managers names
|
|
1335
|
-
if (id && sid && !withBoard((b) => rolesFor(b.db, '', sid, workflowFor(root).params)).includes('manager'))
|
|
1515
|
+
if (id && sid && !withBoard((b) => rolesFor(b.db, '', sid, workflowFor(root).params)).includes('manager')) {
|
|
1516
|
+
refuseUnreadManager();
|
|
1517
|
+
throw new Error(`switching dispatching is the manager's act; ${callerAddress()} is not a manager of this board (params.managers)`);
|
|
1518
|
+
}
|
|
1336
1519
|
const state = id ? setDispatching(root, slug, id === 'on', args.reason ?? rest.slice(1).join(' ')) : dispatchingOf(root, slug);
|
|
1337
1520
|
say(args, `board ${slug}: dispatching ${state.on ? 'on' : 'off'}${state.at ? ` since ${state.at}` : ''}${state.by ? ` by ${state.by}` : ''}${state.reason ? ` (${state.reason})` : ''}`, { board: slug, ...state });
|
|
1338
1521
|
return 0;
|
|
1339
1522
|
}
|
|
1340
1523
|
case 'pause': {
|
|
1341
1524
|
if (id) {
|
|
1342
|
-
await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
|
|
1525
|
+
await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' '), until: args.until } }));
|
|
1343
1526
|
say(args, `${id} paused`, { ok: true }); return 0;
|
|
1344
1527
|
}
|
|
1345
1528
|
// Hermes's emergency stop: new starts stop on every board of the home; running sessions go on
|
|
1346
|
-
writeFileSync(join(root, 'ESTOP'), `${JSON.stringify({ engaged_at: new Date().toISOString(), reason: rest.join(' ') || null })}\n`);
|
|
1529
|
+
writeFileSync(join(root, 'ESTOP'), `${JSON.stringify({ engaged_at: new Date().toISOString(), reason: args.reason ?? (rest.join(' ') || null) })}\n`);
|
|
1347
1530
|
say(args, `New starts paused (${join(root, 'ESTOP')}); \`supercode workflow resume\` lifts it`, { ok: true, paused: join(root, 'ESTOP') });
|
|
1348
1531
|
return 0;
|
|
1349
1532
|
}
|
|
1533
|
+
case 'backlog': case 'plan': {
|
|
1534
|
+
need(1, 'a card id');
|
|
1535
|
+
await withBoardAsync(b => engine.act(b, id, verb, { payload: { reason: args.reason ?? null, ...(args['pause-revision'] ? { pause_revision: args['pause-revision'] } : {}) } }));
|
|
1536
|
+
say(args, `${id} ${verb === 'backlog' ? 'placed in backlog' : 'planned into Todo'}`, { ok: true }); return 0;
|
|
1537
|
+
}
|
|
1350
1538
|
case 'resume': {
|
|
1351
1539
|
if (id) {
|
|
1352
1540
|
await withBoardAsync((b) => engine.act(b, id, 'resume'));
|
|
@@ -1368,7 +1556,7 @@ export async function runVerb(argv) {
|
|
|
1368
1556
|
const person = args.to ?? (principalOf()?.startsWith('person:') ? principalOf() : null);
|
|
1369
1557
|
const detached = withBoard(b => {
|
|
1370
1558
|
const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
|
|
1371
|
-
if (!roles.includes('manager') && !(roles.includes('creator') && principalOf()?.startsWith('person:') && person === principalOf())) throw new Error('Only the manager or the signed-in person who created the card detaches its session.');
|
|
1559
|
+
if (!current().glass && !roles.includes('manager') && !(roles.includes('creator') && principalOf()?.startsWith('person:') && person === principalOf())) throw new Error('Only the manager or the signed-in person who created the card detaches its session.');
|
|
1372
1560
|
return detachSession(b, id, person);
|
|
1373
1561
|
});
|
|
1374
1562
|
say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
|
|
@@ -1392,27 +1580,29 @@ export async function runVerb(argv) {
|
|
|
1392
1580
|
}
|
|
1393
1581
|
case 'adopt': {
|
|
1394
1582
|
need(2, 'a card id and a session');
|
|
1395
|
-
|
|
1583
|
+
// a card's session that broke glass passed this same guard in authorize (its board-verb clause), recorded
|
|
1584
|
+
withBoard(b => { if (!current().glass && !rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
1396
1585
|
if (args.side) {
|
|
1397
1586
|
withBoard((b) => attachSideSession(b, id, rest[1]));
|
|
1398
1587
|
say(args, `${rest[1]} is a side session of ${id}: it works ${id}'s subtasks and comments on ${id}; ${id}'s run and state stay its main session's`, { ok: true, side: rest[1] });
|
|
1399
1588
|
return 0;
|
|
1400
1589
|
}
|
|
1401
|
-
|
|
1590
|
+
await withBoardAsync((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
|
|
1402
1591
|
say(args, `${id} is held for ${rest[1]}; the dispatcher verifies and binds it on its next tick (session_adopted, or adoption_failed with the reason)`, { ok: true, requested: rest[1] });
|
|
1403
1592
|
return 0;
|
|
1404
1593
|
}
|
|
1405
1594
|
case 'release': {
|
|
1406
1595
|
need(2, 'a card id and its side session');
|
|
1407
|
-
withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager releases a side session.'); });
|
|
1596
|
+
withBoard(b => { if (!current().glass && !rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager releases a side session.'); });
|
|
1408
1597
|
withBoard((b) => releaseSideSession(b, id, rest[1]));
|
|
1409
1598
|
say(args, `${rest[1]} is no longer a side session of ${id}`, { ok: true, released: rest[1] });
|
|
1410
1599
|
return 0;
|
|
1411
1600
|
}
|
|
1412
1601
|
case 'goto': {
|
|
1413
1602
|
need(2, 'a card id and the status to take it to');
|
|
1414
|
-
const { _: _words, root: _r, board: _b, json: _j, at: _a, ...fields } = args;
|
|
1603
|
+
const { _: _words, root: _r, board: _b, json: _j, at: _a, 'break-glass': _g, ...fields } = args;
|
|
1415
1604
|
const moved = await withBoardAsync((b) => engine.goto(b, id, rest[1], { payload: { ...fields, reason: args.reason ?? (rest.slice(2).join(' ') || null) } }));
|
|
1605
|
+
if (moved.accepted) { say(args, '', null); return 0; }
|
|
1416
1606
|
say(args, moved.event ? `${id} → ${moved.to} (${moved.event.replace(/_/g, '-')})` : `${id} is already ${moved.to}`, { ok: true, ...moved }); return 0;
|
|
1417
1607
|
}
|
|
1418
1608
|
case 'close': {
|
|
@@ -1426,11 +1616,8 @@ export async function runVerb(argv) {
|
|
|
1426
1616
|
// any other event the board's workflow declares is a verb of its own: `<event> <id>... [reason] [--<field> value]`,
|
|
1427
1617
|
// the flags its payload (the doors a session is told name these)
|
|
1428
1618
|
const name = verb.replace(/-/g, '_');
|
|
1429
|
-
const { workflow } = workflowFor(root);
|
|
1430
|
-
const declared = Object.values(workflow.statuses).some((s) => s.on?.[name]) || Boolean(workflow.on?.[name] || workflow.verbs?.[name] || workflow.run?.on?.[name]);
|
|
1431
|
-
if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
|
|
1432
1619
|
need(1, 'a card id');
|
|
1433
|
-
const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
|
|
1620
|
+
const { _: _words, root: _r, board: _b, json: _j, at: _a, 'break-glass': _g, ids: _i, ...fields } = args;
|
|
1434
1621
|
const payload = { ...fields, reviewed_sha: args['reviewed-sha'] ?? fields.reviewed_sha, pr_head: args['pr-head'] ?? fields.pr_head, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (name === 'request_state' ? null : rest.slice(1).join(' ') || null) };
|
|
1435
1622
|
const results = [];
|
|
1436
1623
|
if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
|
|
@@ -1553,13 +1740,17 @@ function scrub(db) {
|
|
|
1553
1740
|
// Only the transfer copy is lowered. Keep supercode_cards.paused (actor/reason)
|
|
1554
1741
|
// and add Hermes's ordinary blocked event so its own tools can explain/unblock it.
|
|
1555
1742
|
function lowerHermesHolds(db) {
|
|
1556
|
-
const rows = db.prepare(`SELECT t.id,c.paused FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
1557
|
-
WHERE t.status = 'paused' OR (t.status NOT IN ('done','archived','cancelled') AND
|
|
1743
|
+
const rows = db.prepare(`SELECT t.id,c.paused,c.backlog FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
1744
|
+
WHERE t.status = 'paused' OR (t.status NOT IN ('done','archived','cancelled') AND (
|
|
1745
|
+
(c.paused IS NOT NULL AND c.paused != 'null') OR (c.backlog IS NOT NULL AND c.backlog != 'null')))` ).all();
|
|
1558
1746
|
for (const row of rows) {
|
|
1559
1747
|
const pause = json(row.paused, {}) ?? {};
|
|
1748
|
+
const backlog = json(row.backlog, null);
|
|
1560
1749
|
db.prepare("UPDATE tasks SET status='blocked',block_kind='needs_input' WHERE id=?").run(row.id);
|
|
1561
1750
|
db.prepare('INSERT INTO task_events(task_id,kind,payload,created_at) VALUES (?,?,?,?)').run(row.id, 'blocked',
|
|
1562
|
-
JSON.stringify({ kind: 'needs_input', reason: pause.reason ?? 'Paused on the native board', by: pause.by ?? null,
|
|
1751
|
+
JSON.stringify({ kind: 'needs_input', reason: backlog?.reason ?? pause.reason ?? 'Paused on the native board', by: backlog?.by ?? pause.by ?? null,
|
|
1752
|
+
source: backlog ? 'native_backlog_export' : 'native_pause_export', loss: ['backlog and bounded pause are native flags; Hermes receives a needs_input block'],
|
|
1753
|
+
native: { paused: json(row.paused, null), backlog } }), now());
|
|
1563
1754
|
}
|
|
1564
1755
|
}
|
|
1565
1756
|
|
|
@@ -1720,33 +1911,55 @@ function ownCards(db, sessionId) {
|
|
|
1720
1911
|
|
|
1721
1912
|
const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'attachment-read', 'github-usage', 'notify-list', 'capacity', 'help', 'init']);
|
|
1722
1913
|
|
|
1723
|
-
/** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of
|
|
1914
|
+
/** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of that machine's
|
|
1724
1915
|
* daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
|
|
1725
|
-
* address is that agent's main session on the board. Only a positive not-declared answer means no main
|
|
1726
|
-
* identity
|
|
1916
|
+
* address is that agent's main session on the board. Only a positive not-declared answer means no main. An unread
|
|
1917
|
+
* manager identity decides nothing: it is recorded for this call, unread, and the caller's roles from its own run, its
|
|
1918
|
+
* creator record or its arc are computed without it. A call those roles allow proceeds; only a call that would be
|
|
1919
|
+
* refused for want of a role, asked by a session that might be that manager, stops with `manager_identity_unavailable`
|
|
1920
|
+
* (call.mjs refuseUnreadManager), and is never granted by guess. */
|
|
1727
1921
|
async function readAgentMains(root) {
|
|
1728
1922
|
// A lookup belongs to this call, including a refusal. A startup miss must not become the resident owner's
|
|
1729
1923
|
// permanent answer, and a replacement main must revoke the old main's manager role on the next call.
|
|
1730
|
-
const
|
|
1924
|
+
const call = current(), agentMains = call.agentMains;
|
|
1731
1925
|
const managers = [(await loadWorkflow(root).catch(() => null))?.params?.managers ?? []].flat().map(String);
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1926
|
+
call.managerDeadline ??= performance.now() + 5_000;
|
|
1927
|
+
await Promise.all([...new Set(managers)].map(async address => {
|
|
1928
|
+
const matched = /^sc:([^:\s]+):agent:([^:\s]+)$/.exec(address);
|
|
1929
|
+
if (!matched || agentMains.has(address)) return agentMains.get(address);
|
|
1930
|
+
const [, machine, name] = matched;
|
|
1931
|
+
const local = normalMachine(machine) === localMachine();
|
|
1932
|
+
const reading = (async () => {
|
|
1933
|
+
try {
|
|
1934
|
+
const timeoutMs = call.managerDeadline - performance.now();
|
|
1935
|
+
if (timeoutMs <= 0) throw new Error('the call\'s manager identity allowance expired before this lookup');
|
|
1936
|
+
const answer = await machineCall(local ? null : machine, 'harness.v1.agent.show', { name, records_only: true,
|
|
1937
|
+
...(local ? { claim: { pid: process.pid, ppid: process.ppid, session: null, named: null, owner: null } } : {}) }, { timeoutMs });
|
|
1938
|
+
// EXIT_UNKNOWN is the daemon's explicit answer that this named agent is not declared.
|
|
1939
|
+
if (answer?.code === 3) return null;
|
|
1940
|
+
const agent = answer?.agents?.[0];
|
|
1941
|
+
if (answer?.code !== 0 || answer.agents?.length !== 1 || agent?.name !== name ||
|
|
1942
|
+
typeof agent.main_session !== 'string' || !/^sc:[^:\s]+:[^:\s]+:[^:\s]+$/.test(agent.main_session))
|
|
1943
|
+
throw new Error(answer?.text ?? 'the daemon returned no valid named agent and main session');
|
|
1944
|
+
return agent.main_session;
|
|
1945
|
+
} catch (cause) {
|
|
1946
|
+
// a caller outside any session is no agent's main, so an unread main cannot be its identity
|
|
1947
|
+
if (callerSession()) call.managerUnread ??= Object.assign(new Error(`Could not read who the caller is: manager ${address} could not be looked up (${cause.message ?? cause}). No role decision was made.`, { cause }), { code: 'manager_identity_unavailable' });
|
|
1948
|
+
return UNREAD;
|
|
1949
|
+
}
|
|
1950
|
+
})();
|
|
1951
|
+
agentMains.set(address, reading);
|
|
1952
|
+
agentMains.set(address, await reading);
|
|
1953
|
+
}));
|
|
1954
|
+
}
|
|
1955
|
+
const UNREAD = Object.freeze({ unread: true });
|
|
1956
|
+
// an unread main is no main for granting: it names no session
|
|
1957
|
+
function agentMain(address) { const main = current().agentMains.get(address); return typeof main === 'string' ? main : null; }
|
|
1958
|
+
|
|
1959
|
+
function managerSession(sessionId, params = {}, resolved = true) {
|
|
1960
|
+
const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
|
|
1961
|
+
return managers.some(m => m === sessionId || m.endsWith(`:${sessionId}`) || (resolved && agentMain(m)?.endsWith(`:${sessionId}`)));
|
|
1748
1962
|
}
|
|
1749
|
-
function agentMain(address) { return current().agentMains.get(address) ?? null; }
|
|
1750
1963
|
|
|
1751
1964
|
/**
|
|
1752
1965
|
* Who a caller is on card `cardId`, in the workflow's roles: a signed-in terminal person retains their creator role;
|
|
@@ -1756,8 +1969,7 @@ function agentMain(address) { return current().agentMains.get(address) ?? null;
|
|
|
1756
1969
|
*/
|
|
1757
1970
|
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
1758
1971
|
// a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
|
|
1759
|
-
|
|
1760
|
-
if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`) || agentMain(m)?.endsWith(`:${sessionId}`))) return ['manager'];
|
|
1972
|
+
if (managerSession(sessionId, params)) return ['manager'];
|
|
1761
1973
|
const task = engine.getTask(db, cardId);
|
|
1762
1974
|
if (!task) return [];
|
|
1763
1975
|
const roles = [];
|
|
@@ -1768,36 +1980,54 @@ function rolesFor(db, cardId, sessionId, params = {}) {
|
|
|
1768
1980
|
if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
|
|
1769
1981
|
else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
|
|
1770
1982
|
if (task.subtask_of) {
|
|
1983
|
+
// the arc's working session stays its own while another run (its reviewer's) holds the arc, so it still closes the
|
|
1984
|
+
// arc's subtasks, whoever filed them (a reviewer's finding included)
|
|
1771
1985
|
const parent = engine.getTask(db, task.subtask_of);
|
|
1772
|
-
const
|
|
1773
|
-
|
|
1986
|
+
const running = parent?.current_run_id ? engine.currentRun(db, parent.id)?.metadata?.supercode : null;
|
|
1987
|
+
const working = running?.session_id === sessionId || engine.lastSession(db, task.subtask_of)?.session_id === sessionId;
|
|
1988
|
+
if (working || sideSessions(db, task.subtask_of).some((s) => s.session === sessionId)) roles.push('arc');
|
|
1774
1989
|
}
|
|
1775
1990
|
return roles;
|
|
1776
1991
|
}
|
|
1777
1992
|
|
|
1993
|
+
/** The same card-scope guard for an ordinary write and local service admission. Manager authority is resolved by
|
|
1994
|
+
* each caller before it applies this refusal; a service can first discover that no such authority is needed. */
|
|
1995
|
+
function scopedSessionGuard(board, { root, slug, verb, rest, args, sid }) {
|
|
1996
|
+
const own = { ...ownCards(board.db, sid), side: sideArcs(board.db, sid) };
|
|
1997
|
+
if (!own.worked.size && !own.side.size) return null;
|
|
1998
|
+
const copyServe = isServiceVerb(verb) && Boolean(boardCopyOf(board)) && listBoards(root).every(board => !dispatchingOf(root, board).on);
|
|
1999
|
+
const refused = authorize(board.db, verb, rest, args, own, { copyServe });
|
|
2000
|
+
return refused.length ? { refused, scope: { verb, args, own, slug, sid } } : null;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1778
2003
|
/**
|
|
1779
2004
|
* What a session that works a card, or is a side session of an arc (`own.side`), may name: its own cards and their
|
|
1780
2005
|
* subtasks, and the subtasks of the arcs it is a side session of, and those arcs themselves only to comment on. What it
|
|
1781
|
-
* may do to them is the workflow's (rolesFor).
|
|
2006
|
+
* may do to them is the workflow's (rolesFor). Answers every guard the write passes outside that, each as
|
|
2007
|
+
* `{ why, card }` (the card it names, or null for a board verb); none is an ordinary write. A guarded write is not
|
|
2008
|
+
* forbidden: given on purpose with a reason it goes ahead and is recorded (breakGlassOrRefuse).
|
|
1782
2009
|
*/
|
|
1783
|
-
function authorize(db, verb, rest, args, own, { copyServe
|
|
2010
|
+
function authorize(db, verb, rest, args, own, { copyServe }) {
|
|
1784
2011
|
const cards = [...new Set([...own.worked, ...own.side])];
|
|
1785
|
-
const
|
|
1786
|
-
const refuse = (why) => {
|
|
1787
|
-
if (
|
|
2012
|
+
const refused = [];
|
|
2013
|
+
const refuse = (why, card = null) => { if (!refused.some((r) => r.why === why)) refused.push({ why, card }); };
|
|
2014
|
+
if (copyServe) return refused;
|
|
1788
2015
|
if (verb === 'create') {
|
|
1789
|
-
|
|
1790
|
-
|
|
2016
|
+
// a subtask of its own card's subtask is inside its card: the engine answers it (subtasks are one level deep), not this guard
|
|
2017
|
+
const named = args['subtask-of'] ? engine.getTask(db, args['subtask-of']) : null;
|
|
2018
|
+
const ownArc = (id) => own.worked.has(id) || own.side.has(id);
|
|
2019
|
+
if (!ownArc(args['subtask-of']) && !(named?.subtask_of && ownArc(named.subtask_of))) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`, args['subtask-of'] ?? null);
|
|
2020
|
+
return refused;
|
|
1791
2021
|
}
|
|
1792
|
-
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
2022
|
+
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return refused; }
|
|
1793
2023
|
// verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
|
|
1794
2024
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1795
|
-
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'compact', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does
|
|
1796
|
-
if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
|
|
2025
|
+
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'compact', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`, rest[0] ?? null);
|
|
2026
|
+
if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's", rest[0] ?? null);
|
|
1797
2027
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1798
2028
|
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
1799
|
-
if (!own.worked.has(args['for-card'])) refuse(`subscribes only its own arc (--for-card ${[...own.worked].join(' or ')})
|
|
1800
|
-
return;
|
|
2029
|
+
if (!own.worked.has(args['for-card'])) refuse(`subscribes only its own arc (--for-card ${[...own.worked].join(' or ')})`, args['for-card']);
|
|
2030
|
+
return refused;
|
|
1801
2031
|
}
|
|
1802
2032
|
const targets = verb === 'request-state' ? [...rest, ...(args.ids ?? [])] : [rest[0], ...(args.ids ?? [])];
|
|
1803
2033
|
for (const target of targets) {
|
|
@@ -1807,9 +2037,62 @@ function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
|
|
|
1807
2037
|
if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
|
|
1808
2038
|
// the card it names is the one it addresses: any card it holds, or a subtask of one it works
|
|
1809
2039
|
// a side session names its arc only to comment on it; the arc's other verbs are its main session's
|
|
1810
|
-
if (own.side.has(card.id) && !own.held.has(card.id) && verb !== 'comment') refuse(`only comments on ${card.id}, which it is a side session of; the arc's other verbs are its main session's and the manager's
|
|
1811
|
-
if (!own.held.has(card.id) && !own.worked.has(card.subtask_of) && !own.side.has(card.id) && !own.side.has(card.subtask_of)) refuse(`writes only its own card${cards.length > 1 ? 's' : ''} and ${cards.length > 1 ? 'their' : 'its'} subtasks, not ${card.id}
|
|
2040
|
+
if (own.side.has(card.id) && !own.held.has(card.id) && verb !== 'comment') refuse(`only comments on ${card.id}, which it is a side session of; the arc's other verbs are its main session's and the manager's`, card.id);
|
|
2041
|
+
if (!own.held.has(card.id) && !own.worked.has(card.subtask_of) && !own.side.has(card.id) && !own.side.has(card.subtask_of)) refuse(`writes only its own card${cards.length > 1 ? 's' : ''} and ${cards.length > 1 ? 'their' : 'its'} subtasks, not ${card.id}`, card.id);
|
|
2042
|
+
}
|
|
2043
|
+
return refused;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/** Why a card's session's writes outside its own card are guarded, said with every refusal (owner, VO-3793: "the
|
|
2047
|
+
* purpose of this is to prevent the agents from adopting bad behaviors - so what we should do is allow it to be done
|
|
2048
|
+
* but require a reason flag and if that's not provided we complain instead of doing it - explaining why"). */
|
|
2049
|
+
const OUTSIDE_OWN_CARD = 'A card\'s session reports to the fleet\'s manager and writes its own card; a write outside it is '
|
|
2050
|
+
+ 'reserved so agents do not drift into the habit of steering other cards and the board around the manager. If this write '
|
|
2051
|
+
+ 'is needed, repeat it on purpose with --break-glass "<why it is needed>": this guard then lets it through and records it '
|
|
2052
|
+
+ 'with this session, what it wrote and the reason. The workflow\'s own rules for who may make each transition still apply.';
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* A card's session's write that authorize() guards: with `--break-glass "<reason>"` it passes this guard (and the
|
|
2056
|
+
* manager checks adopt, release and detach repeat), recorded as one entry in the machine's break-glass journal
|
|
2057
|
+
* (@volter/teams/break-glass, the record every other supercode guard's deliberate act keeps) naming the session, the
|
|
2058
|
+
* cards or verb it wrote outside its own card, every guard it passed and the reason. Answers that entry; runVerb follows
|
|
2059
|
+
* it with what became of the act when it was not simply done. Without a reason nothing is written, and the refusal
|
|
2060
|
+
* says what was refused, why that write is guarded and how to give the reason. An empty reason is no reason; the flag
|
|
2061
|
+
* alone opens it, never an inherited environment variable. The workflow's `by` roles are not this guard: a transition
|
|
2062
|
+
* they refuse is refused whatever the reason.
|
|
2063
|
+
*/
|
|
2064
|
+
function breakGlassOrRefuse(refused, { verb, args, own, slug, sid }) {
|
|
2065
|
+
const named = [...new Set([...own.worked, ...own.side])].join(', ');
|
|
2066
|
+
const guards = refused.map((r) => `the session working ${named} ${r.why}`);
|
|
2067
|
+
const given = typeof args['break-glass'] === 'string' ? args['break-glass'].trim() : '';
|
|
2068
|
+
if (!given) {
|
|
2069
|
+
refuseUnreadManager();
|
|
2070
|
+
throw guardRefusal(`the session working ${named} ${refused.map((r) => r.why).join('; ')}. Nothing was done`, { refused: refused.map((r) => r.why) }, OUTSIDE_OWN_CARD);
|
|
1812
2071
|
}
|
|
2072
|
+
const reason = breakGlassReason(given, {});
|
|
2073
|
+
const cards = [...new Set(refused.map((r) => r.card).filter(Boolean))];
|
|
2074
|
+
// the journal is this machine's; the agent is the caller's own (its facts), never the board owner's environment
|
|
2075
|
+
const env = { ...process.env };
|
|
2076
|
+
delete env.SUPERCODE_AGENT_ID;
|
|
2077
|
+
const entry = recordBreakGlass({ act: `workflow ${verb}${cards.length ? ` ${cards.join(' ')}` : ''}`,
|
|
2078
|
+
actor: { agent: current().facts?.SUPERCODE_AGENT_ID || null, session: callerAddress() ?? sid },
|
|
2079
|
+
actedFor: cards.length === 1 ? { kind: 'card', id: cards[0] } : cards.length ? { kind: 'cards', id: cards.join(',') } : { kind: 'board', id: slug },
|
|
2080
|
+
reason, detail: { board: slug, own_cards: named, guards } }, { env, stderr: { write: (text) => current().err(text) } });
|
|
2081
|
+
return { entry, env, created: null };
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/** What became of a break-glass act, appended to its journal entry's file: refused or failed (with why), or for a card
|
|
2085
|
+
* filed through it, done with the card's id. An act simply done adds nothing. */
|
|
2086
|
+
function followBreakGlass({ entry, env, created }, failure, code) {
|
|
2087
|
+
const outcome = failure
|
|
2088
|
+
? (failure.code === 'forbidden' || /\bonly\b[^.]*\b(?:manager|implementer|creator|reviewer)|not authori[sz]ed|may not/i.test(failure.message ?? '') ? 'refused' : 'failed')
|
|
2089
|
+
: code ? 'failed' : 'done';
|
|
2090
|
+
if (outcome === 'done' && !created) return;
|
|
2091
|
+
const follow = { at: new Date().toISOString(), follows: entry.at, act: entry.act, actor: entry.actor, acted_for: entry.acted_for, outcome,
|
|
2092
|
+
...(failure ? { error: String(failure.message ?? failure).slice(0, 500) } : code ? { exit: code } : {}), ...(created ? { created } : {}) };
|
|
2093
|
+
try { appendFileSync(breakGlassLog(env), `${JSON.stringify(follow)}\n`, { mode: 0o600 }); }
|
|
2094
|
+
catch (error) { current().err(`supercode: break glass: the outcome of ${entry.act} (${outcome}) could not be recorded: ${error.message}\n`); }
|
|
2095
|
+
if (outcome !== 'done') current().err(`supercode: break glass: ${entry.act} was ${outcome}; recorded in ${breakGlassLog(env)}\n`);
|
|
1813
2096
|
}
|
|
1814
2097
|
|
|
1815
2098
|
/** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
|
|
@@ -1877,7 +2160,11 @@ async function answerVerbs(root, log) {
|
|
|
1877
2160
|
// naming the lock or socket as its input is refused (owner-files.mjs)
|
|
1878
2161
|
let lock;
|
|
1879
2162
|
try { lock = await holdLock(lockPath, { onLost: (error) => { log(`${error.message}: this home has no owner now; stopping`); process.exit(1); } }); }
|
|
1880
|
-
catch (error) {
|
|
2163
|
+
catch (error) {
|
|
2164
|
+
if (error?.code !== 'EHELD') throw new Error(`the boards of ${root} cannot be owned: ${lockPath} could not be locked (${error.message})`, { cause: error });
|
|
2165
|
+
throw new Error(`the boards of ${root} already have their owner (${lockPath} is held: ${error.message}); one serve per home`);
|
|
2166
|
+
}
|
|
2167
|
+
try {
|
|
1881
2168
|
ownHome(home);
|
|
1882
2169
|
// the lock held, this owner takes its fencing epoch and fences every board, before it answers anything (fence.mjs)
|
|
1883
2170
|
const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
|
|
@@ -1888,11 +2175,21 @@ async function answerVerbs(root, log) {
|
|
|
1888
2175
|
log(`owner epoch ${epoch}`);
|
|
1889
2176
|
const path = boardOwnerSocket(root);
|
|
1890
2177
|
try { unlinkSync(path); } catch { /* none left */ }
|
|
1891
|
-
|
|
1892
|
-
try { closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log }); }
|
|
1893
|
-
catch (error) { lock.release(); throw error; }
|
|
2178
|
+
const closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log });
|
|
1894
2179
|
log(`answering the card verbs of ${root} at ${path}`);
|
|
1895
|
-
|
|
2180
|
+
let closing;
|
|
2181
|
+
return () => closing ??= (async () => {
|
|
2182
|
+
const errors = [];
|
|
2183
|
+
try { await closeDoor(); } catch (error) { errors.push(error); }
|
|
2184
|
+
try { unlinkSync(path); } catch (error) { if (error.code !== 'ENOENT') errors.push(error); }
|
|
2185
|
+
try { await lock.release(); } catch (error) { errors.push(error); }
|
|
2186
|
+
if (errors.length) throw new AggregateError(errors, 'board command door shutdown failed', { cause: errors[0] });
|
|
2187
|
+
})();
|
|
2188
|
+
} catch (error) {
|
|
2189
|
+
try { await lock.release(); }
|
|
2190
|
+
catch (cleanup) { throw new AggregateError([error, cleanup], 'board door startup and lock cleanup failed', { cause: error }); }
|
|
2191
|
+
throw error;
|
|
2192
|
+
}
|
|
1896
2193
|
}
|
|
1897
2194
|
|
|
1898
2195
|
/** One request's own steps and the separate process activity during its wait.
|
|
@@ -1904,6 +2201,7 @@ export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
|
1904
2201
|
}
|
|
1905
2202
|
|
|
1906
2203
|
async function answerOneAdmitted(root, line, { readOnly = false } = {}) {
|
|
2204
|
+
return measureWork("door", "board.command", async () => {
|
|
1907
2205
|
const scoped = typeof slowLog.withStepScope === 'function';
|
|
1908
2206
|
// The owner's receipt includes asynchronous custody preparation too: it is part of the caller's wait.
|
|
1909
2207
|
const began = performance.now(), before = slowLog.processCounters?.() ?? null;
|
|
@@ -1945,7 +2243,10 @@ async function answerOneAdmitted(root, line, { readOnly = false } = {}) {
|
|
|
1945
2243
|
}
|
|
1946
2244
|
};
|
|
1947
2245
|
return scoped ? slowLog.withStepScope(guarded) : guarded();
|
|
1948
|
-
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
1949
2250
|
|
|
1950
2251
|
/** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
|
|
1951
2252
|
* one the owner is to answer (listing.mjs byOwner). */
|
|
@@ -1955,22 +2256,43 @@ async function answerRequest(root, line, naming, { readOnly = false } = {}) {
|
|
|
1955
2256
|
const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
|
|
1956
2257
|
const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
|
|
1957
2258
|
if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
|
|
1958
|
-
const
|
|
2259
|
+
const args = parse(argv, { contextOnly: true }), verb = args._[0];
|
|
1959
2260
|
if (readOnly && (tool || !['list', 'ls'].includes(verb))) return { byOwner: true };
|
|
1960
|
-
if (BOARD_OPERATION_VERBS.has(verb)) return { code: 1, stdout: '', stderr: `supercode workflow: ${verb} is run on the board's machine as its own act, not sent to the owner\n` };
|
|
1961
2261
|
const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
|
|
1962
2262
|
const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
|
|
1963
2263
|
naming(tool ? `tool ${tool.name}` : verb, caller);
|
|
1964
2264
|
const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
|
|
1965
|
-
// a
|
|
1966
|
-
// is done, never answered with the owner's
|
|
1967
|
-
if (!facts.SUPERCODE_CONTEXT) return { code: 1, stdout: '', stderr: 'supercode workflow: the request names no Teams context (facts.SUPERCODE_CONTEXT); nothing was done\n' };
|
|
2265
|
+
// Route a contextless request under its own caller, without borrowing the owner's context.
|
|
1968
2266
|
const cwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : root;
|
|
1969
2267
|
const stdin = typeof request.stdin === 'string' ? request.stdin : null;
|
|
1970
2268
|
const stdout = [];
|
|
1971
2269
|
const stderr = [];
|
|
1972
2270
|
let code, byOwner = false;
|
|
1973
2271
|
await within({ caller, facts, cwd, stdin, readOnly, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
|
|
2272
|
+
if (!tool && isServiceVerb(verb)) {
|
|
2273
|
+
let malformed;
|
|
2274
|
+
try { parse(argv); } catch (error) { malformed = error; }
|
|
2275
|
+
if (!malformed) throw localServiceDoorError();
|
|
2276
|
+
const named = typeof request.board === 'string' && request.board ? request.board : boardOf(root, {});
|
|
2277
|
+
current().boardCommand = { root, slug: named };
|
|
2278
|
+
code = await retainUnappliedRequest(argv, malformed);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
const ownerOperation = BOARD_OPERATION_VERBS.has(verb) && !(verb === 'dispatch' && args['dry-run']);
|
|
2282
|
+
if (!facts.SUPERCODE_CONTEXT || ownerOperation) {
|
|
2283
|
+
if (readOnly) { byOwner = true; return; }
|
|
2284
|
+
await loadWorkflow(root);
|
|
2285
|
+
const named = typeof request.board === 'string' && request.board ? request.board : DEFAULT_BOARD;
|
|
2286
|
+
const board = openBoard(root, isView(root, named) ? DEFAULT_BOARD : named);
|
|
2287
|
+
try {
|
|
2288
|
+
setActor(caller ?? 'unresolved board caller');
|
|
2289
|
+
await engine.routeRequest(board, { name: tool?.name ?? verb, payload: tool ? { tool, argv } : { argv }, reason: ownerOperation
|
|
2290
|
+
? 'This operation needs the board machine to settle its running owner before it can act.'
|
|
2291
|
+
: 'The caller context needs to be resolved before the requested act can run.' });
|
|
2292
|
+
say(args, '', null); code = 0;
|
|
2293
|
+
} finally { board.close(); }
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
1974
2296
|
// a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
|
|
1975
2297
|
if (tool) { stdout.push(await boardTool(root, tool.name, tool.args && typeof tool.args === 'object' ? tool.args : {}, Object.fromEntries(Object.entries(tool.env ?? {}).filter(([key, value]) => /^HERMES_(KANBAN_TASK|KANBAN_RUN_ID|KANBAN_BOARD|PROFILE)$/.test(key) && typeof value === 'string')))); code = 0; return; }
|
|
1976
2298
|
try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
|
|
@@ -1988,32 +2310,59 @@ async function answerRequest(root, line, naming, { readOnly = false } = {}) {
|
|
|
1988
2310
|
|
|
1989
2311
|
// Prepared children import and read their declarations without writer authority. Activation and parking are ordered
|
|
1990
2312
|
// on one control chain, so a late park acknowledgement can never park a generation after rollback resumed it.
|
|
1991
|
-
export async function serveBoardGeneration(root,
|
|
2313
|
+
export async function serveBoardGeneration(root, argv) {
|
|
1992
2314
|
if (!inBoardGeneration) throw new Error('a board generation requires its stable owner IPC connection');
|
|
2315
|
+
const args = parse(argv);
|
|
2316
|
+
assertSourceReceipt(args);
|
|
2317
|
+
if (!['serve', 'daemon'].includes(args._[0]) || homeOf(args) !== root)
|
|
2318
|
+
throw new Error('a board generation starts only its stable owner\'s admitted home');
|
|
2319
|
+
// Watch before the preparatory read and keep that watch across activation. The serving loop attaches its own
|
|
2320
|
+
// callbacks to this same watch, so its first stats read reuses the prepared home unless an actual file changed.
|
|
2321
|
+
const unfollow = followHome(root, { onLost: error => {
|
|
2322
|
+
process.stderr.write(`board generation: the home's files can no longer be watched (${error.message})\n`);
|
|
2323
|
+
process.exit(1);
|
|
2324
|
+
} });
|
|
2325
|
+
try { return await serveWatchedBoardGeneration(root, args); }
|
|
2326
|
+
finally { unfollow(); }
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
async function serveWatchedBoardGeneration(root, args) {
|
|
1993
2330
|
// The direct parent has already resolved the machine identity. An unavailable daemon must not consume a second
|
|
1994
2331
|
// fifteen-second lookup before this child can report preparation through its eight-second owner door.
|
|
1995
2332
|
await knowLocalMachine(process.env.SUPERCODE_BOARD_GENERATION_MACHINE);
|
|
1996
2333
|
// readHome owns an SDK transport startup and one orchestration.load, each with its existing 30-second bound.
|
|
1997
2334
|
tellOwner({ type: 'startup-step', step: 'reading the home through its orchestration door', budget_ms: 65000 });
|
|
1998
2335
|
await readHome(root);
|
|
2336
|
+
const restingAdmission = await prepareBoardRestingAdmission(root);
|
|
1999
2337
|
tellOwner({ type: 'startup-step', step: 'reading board storage without writer authority', budget_ms: 8000 });
|
|
2338
|
+
// These substeps name the work inside the same eight-second preparation boundary. They carry no budget,
|
|
2339
|
+
// so progress cannot re-arm that boundary and turn one bounded read into several allowances.
|
|
2340
|
+
const preparedStep = (step, began) => {
|
|
2341
|
+
const ms = performance.now() - began;
|
|
2342
|
+
noteStep(`board preparation: ${step}`, ms);
|
|
2343
|
+
process.stdout.write(`${new Date().toISOString()} board generation ${process.pid}: preparation: ${step} ${Math.round(ms)}ms\n`);
|
|
2344
|
+
};
|
|
2000
2345
|
await within({ cwd: root, readOnly: true, out: () => {}, err: () => {} }, async () => {
|
|
2001
2346
|
for (const slug of listBoards(root)) {
|
|
2347
|
+
const step = `reading board ${slug} storage without writer authority`, began = performance.now();
|
|
2348
|
+
tellOwner({ type: 'startup-step', step });
|
|
2002
2349
|
let board;
|
|
2003
2350
|
try { board = openBoard(root, slug); board.db.prepare('SELECT 1').get(); }
|
|
2004
2351
|
catch (error) { if (error?.code !== READ_BY_OWNER) throw error; }
|
|
2005
|
-
finally { board?.close(); }
|
|
2352
|
+
finally { try { board?.close(); } finally { preparedStep(step, began); } }
|
|
2006
2353
|
}
|
|
2007
2354
|
});
|
|
2008
2355
|
// Hermes's install is located once per process (hermes-cli.mjs), a read of PATH and its files: here, before this
|
|
2009
2356
|
// generation is prepared, so its activation does not spend it while the public door routes nowhere.
|
|
2357
|
+
const hermesBegan = performance.now();
|
|
2358
|
+
tellOwner({ type: 'startup-step', step: 'locating Hermes for this board generation' });
|
|
2010
2359
|
try { (await import('../hermes-cli.mjs')).hermesInstall(); } catch { /* none installed */ }
|
|
2360
|
+
finally { preparedStep('locating Hermes for this board generation', hermesBegan); }
|
|
2011
2361
|
// Each activation and park step in the owner's log (the generation's output is that log), so a reading accounts
|
|
2012
2362
|
// for every second the public door routes nowhere (ADR 0080, "Board ownership").
|
|
2013
2363
|
const say = text => process.stdout.write(`${new Date().toISOString()} board generation ${process.pid}: ${text}\n`);
|
|
2014
2364
|
let begin, started = false, controls = Promise.resolve();
|
|
2015
2365
|
const activation = new Promise(done => { begin = done; });
|
|
2016
|
-
const unwatchHealth = watchOwnerHealth(value => tellOwner({ type: 'health', ...value }));
|
|
2017
2366
|
const unwatchWorkflow = watchWorkflows((at, { workflow, params, profiles }) => tellOwner({ type: 'workflow', root: at, entry: { workflow, params, profiles } }));
|
|
2018
2367
|
const receive = frame => {
|
|
2019
2368
|
if (frame?.type === 'request') {
|
|
@@ -2051,7 +2400,9 @@ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--
|
|
|
2051
2400
|
tellOwner({ type: 'parked' });
|
|
2052
2401
|
say('park: parked');
|
|
2053
2402
|
} else {
|
|
2403
|
+
restingAdmission.preflight();
|
|
2054
2404
|
grantGenerationWriter();
|
|
2405
|
+
await restingAdmission.activateWriter();
|
|
2055
2406
|
if (!started) {
|
|
2056
2407
|
say('activation: the writer granted; the native board dispatcher starts');
|
|
2057
2408
|
tellOwner({ type: 'startup-step', phase: 'serving', step: 'starting the native board dispatcher', budget_ms: 65000 });
|
|
@@ -2076,15 +2427,21 @@ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--
|
|
|
2076
2427
|
process.on('message', receive); process.once('disconnect', disconnected);
|
|
2077
2428
|
// A transition cannot restore database files over committed card writes: retained generations must write the same
|
|
2078
2429
|
// storage format (storage-contract.mjs), and a format change waits for a compatible migration with its own protocol.
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2430
|
+
const contractBegan = performance.now();
|
|
2431
|
+
tellOwner({ type: 'startup-step', step: 'computing the board storage contract' });
|
|
2432
|
+
let contract;
|
|
2433
|
+
try { contract = storageContract({ restingAdmission: restingAdmission.firstReader ? 'first-reader' : restingAdmission.compatible ? 'compatible-reader' : 'legacy-reader' }); }
|
|
2434
|
+
finally { preparedStep('computing the board storage contract', contractBegan); }
|
|
2435
|
+
tellOwner({ type: 'prepared', ...contract, ...restingAdmission.contract });
|
|
2436
|
+
try { await activation; await loadWorkflow(root); return await serve(root, args); }
|
|
2437
|
+
finally { process.off('message', receive); process.off('disconnect', disconnected); unwatchWorkflow(); }
|
|
2082
2438
|
}
|
|
2083
2439
|
|
|
2084
2440
|
let generationServingDue = true;
|
|
2085
2441
|
let wakeGeneration = () => {};
|
|
2086
2442
|
let registerGenerationOwner = async () => {};
|
|
2087
|
-
async function serve(root, args
|
|
2443
|
+
async function serve(root, args) {
|
|
2444
|
+
registerWorkService({role:'board-generation',scope:{kind:'install',id:root}});
|
|
2088
2445
|
// Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
|
|
2089
2446
|
const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
|
|
2090
2447
|
// --max and --failure-limit are params of every tick, read by the workflow as its own
|
|
@@ -2117,7 +2474,7 @@ async function serve(root, args = {}) {
|
|
|
2117
2474
|
const watchRounds = async (reports, began) => {
|
|
2118
2475
|
if (reports.every((r) => !r.error)) {
|
|
2119
2476
|
// A publication the team refused holds this home's publishing where it is (nothing after it is skipped): a fact of
|
|
2120
|
-
// the same kind as rounds that stopped, so it goes
|
|
2477
|
+
// the same kind as rounds that stopped, so it goes to the held dispatcher diagnostics. Only a refusal
|
|
2121
2478
|
// in hand counts: the team's 4xx answer, which the daemon records with the publication (from supercode-teams
|
|
2122
2479
|
// 0.3.44). An attempt still being retried, or a daemon that records no answer, says nothing here.
|
|
2123
2480
|
const held = (await boardPublishing(root))?.failing;
|
|
@@ -2184,7 +2541,8 @@ async function serve(root, args = {}) {
|
|
|
2184
2541
|
const boardMailboxes = () => ['default', ...(existsSync(join(root, 'kanban', 'boards')) ? readdirSync(join(root, 'kanban', 'boards')) : [])].map((slug) => boardAddress(boardLabel(root, slug)));
|
|
2185
2542
|
// Installed daemons bound an ordinary node request at 30s: finish a quiet poll before that server deadline.
|
|
2186
2543
|
const mailWaiter = async () => (await machineCall(null, 'harness.v1.mail.board_wait', { boards: boardMailboxes(), timeout: 20 }, { timeoutMs: 90_000 }))?.woken ?? [];
|
|
2187
|
-
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log, mailWaiter,
|
|
2544
|
+
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log, mailWaiter,
|
|
2545
|
+
ownWrite: (key, board, path) => Boolean(board) && ownBoardWrite(root, board, key, path), onEvent: (key, { uncertain = false, watched, id, board = null } = {}) => {
|
|
2188
2546
|
if (key === 'board-registry') { retireHeldBoards(); refreshNow(); return; }
|
|
2189
2547
|
if (key === 'board-config') {
|
|
2190
2548
|
if (watched !== undefined && board) heldConfigWatched(board, id ?? key, watched);
|
|
@@ -2214,18 +2572,43 @@ async function serve(root, args = {}) {
|
|
|
2214
2572
|
// lock. It names it again to each daemon that starts while it serves: the owner a restarted daemon restores from its
|
|
2215
2573
|
// file stands only if the kernel probe at its start agreed, and an owner it dropped lost the board's authority over the
|
|
2216
2574
|
// panes the board launched (a close refused as "not started by this command").
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2575
|
+
// A registration that did not land is made again on a later round until one does (registerDue), as an owner the
|
|
2576
|
+
// daemon never took has every board close refused, final: the daemon read nothing of who is registering (`unavailable`:
|
|
2577
|
+
// its peer proof or lock reading did not answer; the home is published, no owner taken), this call's own bound ended
|
|
2578
|
+
// first (REQUEST_TIMEOUT, 504: a generation waits 2 s, under the daemon's 5 s proofs, so whether it took the owner is not
|
|
2579
|
+
// known), no daemon answered (machine_offline, ECONNRESET), or the daemon was not serving yet (503: preparing its mail
|
|
2580
|
+
// at its start, or stopping, as on the reopen after a restart), or it answered but took no owner (`owner: false`: its
|
|
2581
|
+
// proof read the lock and found this process not holding it). An answer that says nothing of an owner (where the
|
|
2582
|
+
// daemon cannot read the lock or prove the peer, as on Windows) is taken as it always was, and never asked again for
|
|
2583
|
+
// that. Any other refusal lasts and is not asked again until the daemon reopens. A retry waits first (registerAt),
|
|
2584
|
+
// 15 s doubling to 5 min, and the wait resets when one lands: a retry under
|
|
2585
|
+
// slowness costs its round the generation's 2 s or a daemon's proofs, so it is never paid on every round. A retry says
|
|
2586
|
+
// its outcome only when it changed. A reopen registers at once, after any call in flight (which went to the daemon
|
|
2587
|
+
// that closed), its wait reset. Meanwhile the daemon takes the owner from the home's lock on the board's own calls
|
|
2588
|
+
// (live-sessions.mjs boardOwnerOf), so this is the second way back, not the first.
|
|
2589
|
+
let registerDue = false, registerAt = 0, registerWait = 0, registering = null, registerSaid = null;
|
|
2590
|
+
const registerOwner = (retry = false) => registering ??= (async () => {
|
|
2591
|
+
let named = null, refusal = null, code = null;
|
|
2592
|
+
try { named = await machineCall(null, 'harness.v1.boards.register', { root, claim: { pid: process.pid, ppid: process.ppid } }, { timeoutMs: inBoardGeneration ? 2000 : 30_000 }); } catch (error) { refusal = error.message; code = error?.code ?? null; }
|
|
2593
|
+
const ownerless = refusal === null && named?.owner === false;
|
|
2594
|
+
registerDue = ownerless || (refusal !== null && ['unavailable', 'REQUEST_TIMEOUT', 504, 'machine_offline', 'ECONNRESET', 503].includes(code));
|
|
2595
|
+
registerWait = registerDue ? Math.min(registerWait ? registerWait * 2 : 15_000, 300_000) : 0;
|
|
2596
|
+
registerAt = Date.now() + registerWait;
|
|
2597
|
+
const again = registerDue ? '; registering again on a later round (15 s, doubling to 5 min)' : '';
|
|
2220
2598
|
const refused = named?.refused ?? null;
|
|
2221
|
-
|
|
2599
|
+
const line = code === 'unavailable' ? `board ownership: this home is published, but the machine daemon could not read which process is registering, so it took no owner for it (${refusal})${again}`
|
|
2600
|
+
: code === 'REQUEST_TIMEOUT' || code === 504 ? `board ownership: the machine daemon did not answer the registration in time (${refusal}); whether it published this home and took this owner is not known${again}`
|
|
2601
|
+
: refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}${again}`
|
|
2602
|
+
: ownerless ? `board ownership: this home is registered, but the machine daemon did not find this process holding its board lock, so it took no owner for it${again}`
|
|
2222
2603
|
: refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
|
|
2223
2604
|
: named?.published === false ? `board ownership: registered locally; this machine has no team connector to publish events`
|
|
2224
|
-
: `board events: this home is published by the machine's connector
|
|
2225
|
-
|
|
2605
|
+
: `board events: this home is published by the machine's connector`;
|
|
2606
|
+
if (retry !== true || line !== registerSaid) log(line);
|
|
2607
|
+
registerSaid = line;
|
|
2608
|
+
})().finally(() => { registering = null; });
|
|
2226
2609
|
registerGenerationOwner = registerOwner;
|
|
2227
2610
|
await registerOwner();
|
|
2228
|
-
onLocalReopen(registerOwner);
|
|
2611
|
+
onLocalReopen(async () => { await registering; registerWait = 0; registerAt = 0; return registerOwner(); });
|
|
2229
2612
|
activationSaid('registered with the machine daemon');
|
|
2230
2613
|
// Its writer epoch taken, work admitted and the home registered, this generation answers verbs: its owner routes
|
|
2231
2614
|
// public requests to it now, and its first clean round below gates the activation, never the routing.
|
|
@@ -2233,14 +2616,15 @@ async function serve(root, args = {}) {
|
|
|
2233
2616
|
let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
|
|
2234
2617
|
const listenerErrors = new Set();
|
|
2235
2618
|
const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
|
|
2619
|
+
const listenerSteps = (name, work) => timed(name, work, { scope: 'listener' });
|
|
2236
2620
|
const scanListeners = async ({ waiting = true } = {}) => {
|
|
2237
2621
|
if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
|
|
2238
2622
|
const revision = listenerRevision;
|
|
2239
2623
|
try {
|
|
2240
|
-
await ownCall(function listenerScan() {
|
|
2624
|
+
await listenerSteps('board listener scan', () => ownCall(function listenerScan() {
|
|
2241
2625
|
if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
|
|
2242
2626
|
observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
|
|
2243
|
-
});
|
|
2627
|
+
}));
|
|
2244
2628
|
} catch (error) {
|
|
2245
2629
|
// A scan its stopping generation refused never ran, and this generation scans nothing again: said as that.
|
|
2246
2630
|
if (error?.code === GENERATION_STOPPED) { listenerLog(`fleet listener scan not run: ${error.message}`); return; }
|
|
@@ -2252,7 +2636,7 @@ async function serve(root, args = {}) {
|
|
|
2252
2636
|
const readListenerRecords = () => {
|
|
2253
2637
|
if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
|
|
2254
2638
|
const revision = listenerRevision;
|
|
2255
|
-
listenerReading = readFleetRecords().then(records => {
|
|
2639
|
+
listenerReading = listenerSteps('board listener records', () => readFleetRecords()).then(records => {
|
|
2256
2640
|
if (revision !== listenerRevision || !fleetListeners.enabled || stopped) return;
|
|
2257
2641
|
listenerRecords = records;
|
|
2258
2642
|
if (!listenerBootstrapped) { fleetListeners.bootstrap(records); listenerBootstrapped = true; scanListeners(); }
|
|
@@ -2262,17 +2646,17 @@ async function serve(root, args = {}) {
|
|
|
2262
2646
|
return listenerReading;
|
|
2263
2647
|
};
|
|
2264
2648
|
fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
|
|
2265
|
-
file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key), 'a fleet notice'),
|
|
2649
|
+
file: (to, body, key) => listenerSteps('board listener filing', () => ownCall(() => fileFleetNotice(root, to, body, key), 'a fleet notice')),
|
|
2266
2650
|
// The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
|
|
2267
2651
|
enrich: event => enrichFleetEvent(root, event),
|
|
2268
|
-
work: fn => ownCall(fn, 'the listener flush'),
|
|
2652
|
+
work: fn => listenerSteps('board listener flush', () => ownCall(fn, 'the listener flush')),
|
|
2269
2653
|
retry: () => scanListeners({ waiting: false }),
|
|
2270
2654
|
});
|
|
2271
2655
|
const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
|
|
2272
2656
|
reloadListeners = () => {
|
|
2273
2657
|
listenerReloadDue = true;
|
|
2274
2658
|
if (listenerReloading || stopped) return listenerReloading;
|
|
2275
|
-
listenerReloading = (async () => {
|
|
2659
|
+
listenerReloading = listenerSteps('board listener configuration', async () => {
|
|
2276
2660
|
while (listenerReloadDue && !stopped) {
|
|
2277
2661
|
listenerReloadDue = false;
|
|
2278
2662
|
const revision = listenerRevision;
|
|
@@ -2291,7 +2675,7 @@ async function serve(root, args = {}) {
|
|
|
2291
2675
|
listenerReloadDue = true; break;
|
|
2292
2676
|
}
|
|
2293
2677
|
}
|
|
2294
|
-
})
|
|
2678
|
+
}).finally(() => { listenerReloading = null; });
|
|
2295
2679
|
return listenerReloading;
|
|
2296
2680
|
};
|
|
2297
2681
|
if (inBoardGeneration && generationServingDue) tellOwner({ type: 'startup-step', phase: 'serving', step: 'reading the listener configuration through its orchestration door', budget_ms: 65000 });
|
|
@@ -2311,8 +2695,11 @@ async function serve(root, args = {}) {
|
|
|
2311
2695
|
// a board whose cards name an assignee that is no profile of this home is said once, until it has none
|
|
2312
2696
|
const laneSaid = new Set();
|
|
2313
2697
|
let wakeReason = null;
|
|
2698
|
+
let failure, failed = false;
|
|
2314
2699
|
try {
|
|
2315
2700
|
while (!stopped) {
|
|
2701
|
+
// an owner not yet taken is registered before a round once its wait is over (registerOwner)
|
|
2702
|
+
if (registerDue && Date.now() >= registerAt) await registerOwner(true);
|
|
2316
2703
|
if (listenerReloadDue) void reloadListeners();
|
|
2317
2704
|
if (fleetListeners.enabled) scanListeners();
|
|
2318
2705
|
// A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
|
|
@@ -2322,6 +2709,7 @@ async function serve(root, args = {}) {
|
|
|
2322
2709
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
2323
2710
|
let interval = 60;
|
|
2324
2711
|
let nextMailAt = Infinity;
|
|
2712
|
+
let nextPauseAt = Infinity;
|
|
2325
2713
|
const began = Date.now();
|
|
2326
2714
|
if (inBoardGeneration && generationServingDue) {
|
|
2327
2715
|
tellOwner({ type: 'startup-step', phase: 'serving', step: 'completing the first native board round', budget_ms: 65000 });
|
|
@@ -2330,6 +2718,7 @@ async function serve(root, args = {}) {
|
|
|
2330
2718
|
if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
2331
2719
|
wakeReason = null;
|
|
2332
2720
|
const loopStep = async (name, fn) => { const at = performance.now(); try { return await fn(); } finally { noteStep(`owner loop: ${name}`, performance.now() - at); } };
|
|
2721
|
+
const runRound = async () => {
|
|
2333
2722
|
try {
|
|
2334
2723
|
const state = await loopStep('lane state', () => ownCall(() => laneState(root), 'the lane state'));
|
|
2335
2724
|
const settings = { ...state.settings, ...override };
|
|
@@ -2344,7 +2733,7 @@ async function serve(root, args = {}) {
|
|
|
2344
2733
|
// with the step's budget, so a round slow only on another machine's answers is not ended for its length.
|
|
2345
2734
|
const progress = inBoardGeneration && generationServingDue
|
|
2346
2735
|
? (step) => tellOwner({ type: 'startup-step', phase: 'serving', step: `completing the first native board round (${step})`, budget_ms: 65000 }) : null;
|
|
2347
|
-
const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true, progress }), 'the dispatch round') : [];
|
|
2736
|
+
const reports = dispatching.length ? await loopStep('dispatch', () => ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true, progress }), 'the dispatch round')) : [];
|
|
2348
2737
|
if (inBoardGeneration && generationServingDue && !reports.cut && reports.every(report => !report.error)) {
|
|
2349
2738
|
activationSaid(`the first native board round is clean (${dispatching.length} dispatching board(s))`);
|
|
2350
2739
|
generationServingDue = false;
|
|
@@ -2355,16 +2744,18 @@ async function serve(root, args = {}) {
|
|
|
2355
2744
|
const resting = listBoards(root).filter((slug) => !dispatching.includes(slug));
|
|
2356
2745
|
const closes = resting.length && !stopped ? await loopStep('close pass', () => ownCall(() => closeEnded({ root, slugs: resting, settings }), 'the close pass')) : [];
|
|
2357
2746
|
for (const r of closes) {
|
|
2747
|
+
nextPauseAt = Math.min(nextPauseAt, r.next_pause_at == null ? Infinity : r.next_pause_at * 1000);
|
|
2358
2748
|
if (r.error || r.closed.length || args.verbose) log(summary(r));
|
|
2359
2749
|
for (const [t, to, why] of r.undelivered) log(` close of ${to} for ${t} waits: ${why}`);
|
|
2360
2750
|
}
|
|
2361
2751
|
if (dispatching.length) {
|
|
2362
2752
|
round += 1;
|
|
2363
2753
|
// what the round recorded is delivered by a pass of its own, which this loop never waits for
|
|
2364
|
-
if (!stopped)
|
|
2365
|
-
if (!stopped) await watchRounds(reports, began);
|
|
2754
|
+
if (!stopped) await loopStep('round health', () => watchRounds(reports, began));
|
|
2366
2755
|
}
|
|
2756
|
+
if (!stopped) startDelivery(root, { slugs: [...dispatching, ...resting], log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
|
|
2367
2757
|
for (const r of reports) {
|
|
2758
|
+
nextPauseAt = Math.min(nextPauseAt, r.next_pause_at == null ? Infinity : r.next_pause_at * 1000);
|
|
2368
2759
|
nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
|
|
2369
2760
|
const said = summary(r);
|
|
2370
2761
|
if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
|
|
@@ -2389,15 +2780,15 @@ async function serve(root, args = {}) {
|
|
|
2389
2780
|
if (!stopped) await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }], began);
|
|
2390
2781
|
}
|
|
2391
2782
|
}
|
|
2392
|
-
|
|
2393
|
-
|
|
2783
|
+
};
|
|
2784
|
+
await timed('dispatch round', runRound, { scope: 'round' });
|
|
2394
2785
|
// a newer install is looked for after each tick and through the wait before the next
|
|
2395
2786
|
const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
|
|
2396
2787
|
let moved = newer();
|
|
2397
2788
|
// the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
|
|
2398
2789
|
await loopStep('wake refresh', () => refreshNow());
|
|
2399
2790
|
if (!stopped && !moved) {
|
|
2400
|
-
wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now())));
|
|
2791
|
+
wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now(), nextPauseAt - Date.now())));
|
|
2401
2792
|
// A busy event stream is coalesced to at most one tick per second.
|
|
2402
2793
|
const remaining = 1000 - (Date.now() - began);
|
|
2403
2794
|
if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
|
|
@@ -2405,19 +2796,33 @@ async function serve(root, args = {}) {
|
|
|
2405
2796
|
}
|
|
2406
2797
|
if (moved && !inBoardGeneration) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
2407
2798
|
}
|
|
2799
|
+
} catch (error) {
|
|
2800
|
+
failed = true; failure = error; throw error;
|
|
2408
2801
|
} finally {
|
|
2409
|
-
|
|
2410
|
-
await
|
|
2411
|
-
await
|
|
2802
|
+
const errors = [];
|
|
2803
|
+
const attempt = async work => { try { await work(); } catch (error) { errors.push(error); } };
|
|
2804
|
+
await attempt(() => fleetListeners.suspend());
|
|
2805
|
+
clearInterval(listenerTimer);
|
|
2806
|
+
await attempt(() => unfollowRecords());
|
|
2807
|
+
await attempt(() => unfollowPanes());
|
|
2808
|
+
await attempt(() => listenerReloading);
|
|
2809
|
+
await attempt(() => listenerReading);
|
|
2412
2810
|
try { await fleetListeners.close(); } catch (error) { log(`fleet listener shutdown: retained filing outcome unknown (${String(error.message).slice(0, 300)})`); }
|
|
2413
2811
|
// a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
|
|
2414
2812
|
try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
await
|
|
2418
|
-
|
|
2813
|
+
await attempt(() => unwatchLoop?.());
|
|
2814
|
+
await attempt(() => releaseHeldBoards());
|
|
2815
|
+
await attempt(() => closeVerbs());
|
|
2816
|
+
await attempt(() => unfollowHome());
|
|
2817
|
+
await attempt(() => closeDoors());
|
|
2818
|
+
// The home reader owns a harness process too. Its idle timer cannot join that child's exit after this
|
|
2819
|
+
// generation checkpoints and exits; close its shared transport while observation is still open.
|
|
2820
|
+
await attempt(async () => { const { closeWorld } = await import('../orchestration.mjs'); await closeWorld(); });
|
|
2821
|
+
await attempt(() => events?.close());
|
|
2419
2822
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.off(signal, stop);
|
|
2420
2823
|
if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
|
|
2824
|
+
await closeWorkLedger();
|
|
2825
|
+
if (errors.length) throw new AggregateError([...(failed ? [failure] : []), ...errors], 'board dispatcher shutdown failed', { cause: failed ? failure : errors[0] });
|
|
2421
2826
|
}
|
|
2422
2827
|
log(stopped ? 'stopped (a signal)' : 'stopped');
|
|
2423
2828
|
return 0;
|