@volter/supercode-orchestrator 0.5.205 → 0.5.207
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 +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +278 -97
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +305 -80
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +92 -27
- package/board/files.mjs +17 -3
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +8 -2
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/notices.mjs +5 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +84 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +96 -31
- package/board/ztrack.mjs +207 -32
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
package/board/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
|
|
2
2
|
// format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
|
|
3
|
-
// output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`set`, `adopt`, `close`,
|
|
3
|
+
// output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`set`, `adopt`, `detach`, `close`,
|
|
4
4
|
// `serve`, `--at`) are added beside them. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`,
|
|
5
5
|
// which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`; the board is
|
|
6
6
|
// `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
|
|
@@ -10,26 +10,28 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
10
10
|
import { homeOf, parse } from './args.mjs';
|
|
11
11
|
import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from '@volter/teams/board-owner';
|
|
12
12
|
import { current, isCallersCall, ownCall, within } from './call.mjs';
|
|
13
|
+
import { detachSession } from './custody.mjs';
|
|
13
14
|
import { taskStatusOf } from './published.mjs';
|
|
14
15
|
import { executionOf } from './session-work.mjs';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
17
|
+
import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
|
|
17
18
|
import { redactText } from '@volter/teams/redact';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
19
|
+
import { openOwnerDoor } from './owner-door.mjs';
|
|
20
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
20
21
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
21
22
|
import { execFile, spawnSync } from 'node:child_process';
|
|
22
23
|
import { holdLock } from './lock-holder.mjs';
|
|
23
24
|
import { takeEpoch } from './fence.mjs';
|
|
24
25
|
import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
|
|
25
26
|
import { boardTool } from './tools.mjs';
|
|
27
|
+
import { assertSourceReceipt } from './ci.mjs';
|
|
26
28
|
import { noteStep, recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
|
|
27
29
|
// read by name, so an older harness SDK without them still loads: the owner then keeps no per-verb or loop lines
|
|
28
30
|
import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
|
|
29
31
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
30
32
|
import { fileURLToPath } from 'node:url';
|
|
31
33
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
32
|
-
import { actorOf, CALLER_RUN_ADDRESS, SESSION_CURRENT_RUNS, SESSION_RUNS, sessionIndexed, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
|
|
34
|
+
import { 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';
|
|
33
35
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
34
36
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
35
37
|
import * as engine from './engine.mjs';
|
|
@@ -69,9 +71,12 @@ async function boardPublishing(root) {
|
|
|
69
71
|
import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
70
72
|
import { watchBoard } from './wake.mjs';
|
|
71
73
|
import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
|
|
72
|
-
import { onPanesChanged } from './machine-fleet.mjs';
|
|
74
|
+
import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
|
|
75
|
+
import { FleetListenerRouter, fleetListenerStatus } from './fleet-listeners.mjs';
|
|
76
|
+
import { enrichFleetEvent, observeRoleFleet } from './fleet-observer.mjs';
|
|
77
|
+
import { fileFleetNotice } from './dispatch.mjs';
|
|
73
78
|
import { streamBoardPublications } from './event-stream.mjs';
|
|
74
|
-
import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
79
|
+
import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
75
80
|
import { evaluate } from './expr.mjs';
|
|
76
81
|
import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
|
|
77
82
|
|
|
@@ -88,24 +93,27 @@ const callerPath = (named) => notRuntime(resolve(current().cwd, named));
|
|
|
88
93
|
const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
89
94
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
90
95
|
|
|
96
|
+
A card is named by its id (t_4406c88a) or by its number (42 or #42): the short number the board gave it when it was made.
|
|
97
|
+
|
|
91
98
|
The board's verbs, with their flags:
|
|
92
99
|
init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
|
|
93
100
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
94
101
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
95
102
|
create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
96
103
|
[--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
|
|
97
|
-
[--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
104
|
+
[--model] [--provider] [--reviewer PROFILE] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
98
105
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
99
106
|
list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> brief <id> [--run N] runs <id>
|
|
100
|
-
set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
107
|
+
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]
|
|
101
108
|
reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
|
|
102
109
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
103
110
|
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
104
|
-
|
|
111
|
+
attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
|
|
112
|
+
complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
|
|
105
113
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
106
114
|
pause <id> --reason <why> resume <id> cancel <id> --reason <why>
|
|
107
115
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
108
|
-
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
116
|
+
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
109
117
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
110
118
|
specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
111
119
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
@@ -131,7 +139,7 @@ And:
|
|
|
131
139
|
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
132
140
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
133
141
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
134
|
-
adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> close <id> serve (= daemon) list --machine M
|
|
142
|
+
adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> detach <id> [--to person:ID] close <id> serve (= daemon) list --machine M
|
|
135
143
|
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
136
144
|
notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
|
|
137
145
|
notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
|
|
@@ -148,6 +156,7 @@ And:
|
|
|
148
156
|
request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
|
|
149
157
|
request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
|
|
150
158
|
complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
|
|
159
|
+
listeners status [--json]: declared role fleet listeners, retained filing counts, source cursors and gaps.
|
|
151
160
|
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
152
161
|
heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. A side session
|
|
153
162
|
(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
|
|
@@ -255,17 +264,40 @@ const callerName = () => current().facts.HERMES_PROFILE || current().facts.USER
|
|
|
255
264
|
* automation running it, its own plain session, or the person its Teams login names. Never the OS user; null when
|
|
256
265
|
* nothing names one.
|
|
257
266
|
*/
|
|
258
|
-
async function
|
|
267
|
+
async function ownerStep(name, fn) {
|
|
268
|
+
const began = performance.now();
|
|
269
|
+
try { return await fn(); }
|
|
270
|
+
finally { noteStep(`owner preparation: ${name}`, performance.now() - began); }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function recordedRunAgent(db, sid, address) {
|
|
274
|
+
const rows = db.prepare(CALLER_RUN_AGENT).all(sid, address);
|
|
275
|
+
const owners = new Map();
|
|
276
|
+
for (const row of rows) {
|
|
277
|
+
if (!/^agent_[0-9a-f]{32}$/.test(row.agent_id) || typeof row.org !== 'string' || !row.org) continue;
|
|
278
|
+
owners.set(JSON.stringify([row.org, row.agent_id]), row);
|
|
279
|
+
}
|
|
280
|
+
if (owners.size > 1) throw new Error('recorded native session has conflicting agent ownership');
|
|
281
|
+
return owners.values().next().value ?? null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function callerPrincipal(root, sid, { teams = false, recordedAgent = null } = {}) {
|
|
259
285
|
const facts = current().facts;
|
|
286
|
+
if (recordedAgent && facts.SUPERCODE_AGENT_ID && facts.SUPERCODE_AGENT_ID !== recordedAgent.agent_id)
|
|
287
|
+
throw new Error('native session ownership does not match its launch identity');
|
|
288
|
+
if (recordedAgent && facts.SUPERCODE_AGENT_ORG && facts.SUPERCODE_AGENT_ORG !== recordedAgent.org)
|
|
289
|
+
throw new Error('native session Org does not match its launch identity');
|
|
260
290
|
if (facts.SUPERCODE_AGENT_ID) return `agent:${facts.SUPERCODE_AGENT_ID}`;
|
|
261
291
|
if (facts.SUPERCODE_AUTOMATION) return `automation:${facts.SUPERCODE_AUTOMATION}`;
|
|
262
292
|
if (sid) {
|
|
263
293
|
const harness = boardCaller()?.harness ?? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code');
|
|
264
|
-
const owner = agentRunForSession(root, [`${harness}:${sid}`]);
|
|
265
|
-
if (owner
|
|
294
|
+
const owner = await ownerStep('local agent ownership', () => agentRunForSession(root, [`${harness}:${sid}`]));
|
|
295
|
+
if (owner && recordedAgent && (owner.agent_id !== recordedAgent.agent_id || owner.org !== recordedAgent.org))
|
|
296
|
+
throw new Error('local and recorded native session ownership conflict');
|
|
297
|
+
if (owner || recordedAgent) return `agent:${(owner ?? recordedAgent).agent_id}`;
|
|
266
298
|
// a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
|
|
267
299
|
let declared = null;
|
|
268
|
-
try { declared = (await agentDoor('session', [`${harness}:${sid}`], { context: current().facts.SUPERCODE_CONTEXT })).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
|
|
300
|
+
try { declared = (await ownerStep('Teams session ownership', () => agentDoor('session', [`${harness}:${sid}`], { context: current().facts.SUPERCODE_CONTEXT }))).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
|
|
269
301
|
return declared ? `agent:${declared}` : `session:${callerAddress()}`;
|
|
270
302
|
}
|
|
271
303
|
if (!teams) return null;
|
|
@@ -299,14 +331,15 @@ const ts = (t) => {
|
|
|
299
331
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
300
332
|
};
|
|
301
333
|
// a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
|
|
302
|
-
const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)}${['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
334
|
+
const line = (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(t.number, t.id).padEnd(16)} ${(t.stage ?? t.status).padEnd(8)}${t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
303
335
|
const cardDict = (t) => ({
|
|
304
|
-
id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
336
|
+
id: t.id, number: t.number ?? null, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
305
337
|
workspace_kind: t.workspace_kind, repository: t.workspace_anchor ?? null, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
|
|
306
338
|
created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
307
339
|
skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
|
|
308
340
|
session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
|
|
309
|
-
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null,
|
|
341
|
+
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null, detached: t.detached ?? null,
|
|
342
|
+
reviewer: t.reviewer ?? null,
|
|
310
343
|
});
|
|
311
344
|
|
|
312
345
|
/** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
|
|
@@ -335,14 +368,52 @@ const metadataOf = (v) => {
|
|
|
335
368
|
return got;
|
|
336
369
|
};
|
|
337
370
|
|
|
371
|
+
// A card is named by its id or by its number (`42`, `#42`, store.mjs layOutNumbers) in every place a verb takes a card:
|
|
372
|
+
// its card positionals (none, the first, the first two, or all of them) and its card flags. A number is read as its
|
|
373
|
+
// card's id before anything else reads the verb (its authorization included); a number no card here has is refused.
|
|
374
|
+
const NO_CARD_WORDS = new Set(['create', 'new', 'swarm', 'list', 'ls', 'migrate', 'init', 'boards', 'usage', 'events', 'listeners', 'dispatch',
|
|
375
|
+
'capacity', 'deliver', 'daemon', 'serve', 'gc', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
|
|
376
|
+
'watch', 'help', 'github-usage']);
|
|
377
|
+
// the verbs that act on every positional as a card (each loops over them all)
|
|
378
|
+
const ALL_CARD_WORDS = new Set(['complete', 'archive', 'request-state', 'request_state', 'set-goal', 'unblock', 'reopen', 'reopen-review']);
|
|
379
|
+
const CARD_FLAGS = ['ids', 'subtask-of', 'parent', 'on', 'for-card', 'task', 'rm'];
|
|
380
|
+
const CARD_NUMBER = /^#?([1-9]\d*)$/;
|
|
381
|
+
function resolveCardNumbers(verb, rest, args, withBoard) {
|
|
382
|
+
const words = NO_CARD_WORDS.has(verb) ? 0 : ALL_CARD_WORDS.has(verb) ? rest.length : ['link', 'unlink'].includes(verb) ? 2 : 1;
|
|
383
|
+
const places = [];
|
|
384
|
+
for (let i = 0; i < Math.min(words, rest.length); i++) places.push([rest, i]);
|
|
385
|
+
for (const flag of CARD_FLAGS) {
|
|
386
|
+
if (Array.isArray(args[flag])) args[flag].forEach((_, i) => places.push([args[flag], i]));
|
|
387
|
+
else if (typeof args[flag] === 'string') places.push([args, flag]);
|
|
388
|
+
}
|
|
389
|
+
const numbered = places.filter(([holder, key]) => CARD_NUMBER.test(String(holder[key])));
|
|
390
|
+
if (!numbered.length) return;
|
|
391
|
+
withBoard((b) => view(b, (db) => {
|
|
392
|
+
for (const [holder, key] of numbered) {
|
|
393
|
+
const n = Number(CARD_NUMBER.exec(String(holder[key]))[1]);
|
|
394
|
+
const id = engine.cardByNumber(db, n);
|
|
395
|
+
if (!id) throw new Error(`no card #${n} on this board`);
|
|
396
|
+
holder[key] = id;
|
|
397
|
+
}
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
|
|
338
401
|
/** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
|
|
339
402
|
export async function runVerb(argv) {
|
|
340
|
-
await knowLocalMachine();
|
|
341
403
|
const args = parse(argv);
|
|
404
|
+
assertSourceReceipt(args);
|
|
405
|
+
await ownerStep('machine identity', () => knowLocalMachine());
|
|
342
406
|
const [verb, ...rest] = args._;
|
|
343
407
|
if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
|
|
344
408
|
const root = homeOf(args);
|
|
345
409
|
if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
|
|
410
|
+
if (verb === 'listeners') {
|
|
411
|
+
if (rest.length && rest[0] !== 'status') throw new Error('workflow listeners accepts status; declarations live in workflow.yaml listeners');
|
|
412
|
+
const orchestration = await readHome(root);
|
|
413
|
+
const status = { declarations: orchestration.workflow?.listeners ?? {}, ...fleetListenerStatus(root) };
|
|
414
|
+
say(args, JSON.stringify(status, null, 2), status);
|
|
415
|
+
return 0;
|
|
416
|
+
}
|
|
346
417
|
// C4: one task store per Org. A named board without a store of its own is a saved view over it: its tag selects
|
|
347
418
|
// the cards it shows, and a card filed through it carries that tag.
|
|
348
419
|
const named = boardOf(root, args);
|
|
@@ -351,26 +422,42 @@ export async function runVerb(argv) {
|
|
|
351
422
|
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; };
|
|
352
423
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
353
424
|
const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
|
|
425
|
+
resolveCardNumbers(verb, rest, args, withBoard);
|
|
354
426
|
const id = rest[0];
|
|
355
427
|
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
356
428
|
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
357
429
|
|
|
358
430
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
359
431
|
const listing = ['list','ls'].includes(verb);
|
|
360
|
-
|
|
432
|
+
const readingDispatching = verb === 'dispatching' && rest.length === 0;
|
|
433
|
+
const reading = READS.has(verb) || readingDispatching;
|
|
434
|
+
// a listing on the owner's read door reads the workflow the owner holds (owner-door.mjs), and names no session: it
|
|
435
|
+
// records nothing, and `--mine` is the caller's profile
|
|
436
|
+
const readDoor = Boolean(current().readOnly);
|
|
437
|
+
if (!readDoor) await ownerStep('workflow', () => loadWorkflow(root)).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
361
438
|
// A listing does not authorize a transition and needs no manager's main session.
|
|
362
|
-
if (!listing) await readAgentMains(root);
|
|
363
|
-
const sid = listing && !args.mine ? null : callerSession();
|
|
439
|
+
if (!listing && !readingDispatching) await ownerStep('manager main sessions', () => readAgentMains(root));
|
|
440
|
+
const sid = listing && (!args.mine || readDoor) ? null : callerSession();
|
|
364
441
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
365
442
|
current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
|
|
366
443
|
if (sid) setActor(callerAddress());
|
|
367
|
-
|
|
444
|
+
const recordedAgent = sid && boardExists(root, slug)
|
|
445
|
+
? await ownerStep('committed native agent ownership', () => withBoard((b) => recordedRunAgent(b.db, sid, callerAddress()))) : null;
|
|
446
|
+
setPrincipal(await ownerStep('caller principal', () => callerPrincipal(root, sid, { teams: !sid && !reading, recordedAgent })));
|
|
368
447
|
// the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
|
|
369
448
|
if (!current().said.has(`dispatcher:${slug}`)) { current().said.add(`dispatcher:${slug}`); for (const line of dispatcherNotice(slug)) current().err(`${line}\n`); }
|
|
370
|
-
if (sid && !
|
|
449
|
+
if (sid && !reading && boardExists(root, slug)) {
|
|
371
450
|
// binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
|
|
372
451
|
// never writes)
|
|
373
|
-
await withBoardAsync(async (b) => {
|
|
452
|
+
await withBoardAsync(async (b) => {
|
|
453
|
+
if (isCallersCall()) await reconcileCallerLaunch(b, sid);
|
|
454
|
+
const own = { ...ownCards(b.db, sid), side: sideArcs(b.db, sid) };
|
|
455
|
+
if (!own.worked.size && !own.side.size) return;
|
|
456
|
+
// A copied home owns no live sessions or manager addresses. Its owner may
|
|
457
|
+
// answer local verbs while every board's dispatching remains off.
|
|
458
|
+
const copyServe = verb === 'serve' && Boolean(boardCopyOf(b)) && listBoards(root).every(board => !dispatchingOf(root, board).on);
|
|
459
|
+
authorize(b.db, verb, rest, args, own, { copyServe });
|
|
460
|
+
});
|
|
374
461
|
}
|
|
375
462
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
376
463
|
// every verb reads the board through its workflow (a list settles cards too), so every verb loads it
|
|
@@ -466,6 +553,7 @@ export async function runVerb(argv) {
|
|
|
466
553
|
const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
|
|
467
554
|
const created = await withBoardAsync(async (b) => engine.createCard(b, await engine.prepareCard(b, {
|
|
468
555
|
title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
556
|
+
reviewer: args.reviewer ?? null,
|
|
469
557
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
470
558
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
471
559
|
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? null,
|
|
@@ -491,7 +579,7 @@ export async function runVerb(argv) {
|
|
|
491
579
|
const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
|
|
492
580
|
if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
|
|
493
581
|
const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
|
|
494
|
-
say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
|
|
582
|
+
say(args, `${created.existing ? 'Existing' : 'Created'} ${engine.cardLabel(task.number, created.id)} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
|
|
495
583
|
return answer.failed ? 1 : 0;
|
|
496
584
|
}
|
|
497
585
|
|
|
@@ -543,7 +631,12 @@ export async function runVerb(argv) {
|
|
|
543
631
|
got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
|
|
544
632
|
got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit, launchWindowSeconds: settings.launchWindow });
|
|
545
633
|
got.stage = statusOf(b.db, got.task);
|
|
546
|
-
|
|
634
|
+
const entry = workflowFor(root);
|
|
635
|
+
// A workflow may explicitly replace the built-in time field. Keep its
|
|
636
|
+
// full derived scope; ordinary totals need only this card's runs.
|
|
637
|
+
got.time = Object.hasOwn(entry.workflow.derived ?? {}, 'time')
|
|
638
|
+
? scopeOf(b.db, b, got.task, { params: entry.params }).card.time
|
|
639
|
+
: timeOf(b.db, got.task);
|
|
547
640
|
return got;
|
|
548
641
|
});
|
|
549
642
|
const t = card.task;
|
|
@@ -552,12 +645,16 @@ export async function runVerb(argv) {
|
|
|
552
645
|
say(args, '', { task: cardDict(t), side_sessions: withBoard((b) => sideSessions(b.db, t.id)), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
|
|
553
646
|
return 0;
|
|
554
647
|
}
|
|
555
|
-
const
|
|
648
|
+
const labels = withBoard((b) => view(b, (db) => engine.numbersOf(db, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
|
|
649
|
+
const label = (cardId) => engine.cardLabel(labels.get(cardId), cardId);
|
|
650
|
+
const out = [`Task ${engine.cardLabel(t.number, t.id)}: ${t.title}`, ` status: ${taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage}${card.stage !== t.status ? `, ${t.status} in kanban.db` : ''})`, ` assignee: ${t.assignee || '-'}`];
|
|
556
651
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
652
|
+
if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
|
|
653
|
+
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)`);
|
|
557
654
|
// where the card's time went: each role's runs, and the rest waiting
|
|
558
655
|
const mins = (sec) => `${Math.round(sec / 60)}m`;
|
|
559
656
|
out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
|
|
560
|
-
if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
|
|
657
|
+
if (t.subtask_of) out.push(` subtask of: ${label(t.subtask_of)}`);
|
|
561
658
|
if (t.tenant) out.push(` tenant: ${t.tenant}`);
|
|
562
659
|
out.push(` workspace: ${t.workspace_kind}${t.workspace_anchor ? `:${t.workspace_anchor}` : ''}`);
|
|
563
660
|
if (t.workspace_path) out.push(` resolved: ${t.workspace_path}`);
|
|
@@ -576,9 +673,9 @@ export async function runVerb(argv) {
|
|
|
576
673
|
}
|
|
577
674
|
if (t.started_at) out.push(` started: ${ts(t.started_at)}`);
|
|
578
675
|
if (t.completed_at) out.push(` completed: ${ts(t.completed_at)}`);
|
|
579
|
-
if (card.parents.length) out.push(` parents: ${card.parents.map((p) => p.id).join(', ')}`);
|
|
580
|
-
if (card.children.length) out.push(` children: ${card.children.join(', ')}`);
|
|
581
|
-
if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${s.id} (${s.status})`).join(', ')}`);
|
|
676
|
+
if (card.parents.length) out.push(` parents: ${card.parents.map((p) => label(p.id)).join(', ')}`);
|
|
677
|
+
if (card.children.length) out.push(` children: ${card.children.map(label).join(', ')}`);
|
|
678
|
+
if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${engine.cardLabel(s.number, s.id)} (${s.status})`).join(', ')}`);
|
|
582
679
|
const side = withBoard((b) => sideSessions(b.db, t.id));
|
|
583
680
|
if (side.length) out.push(` side sessions: ${side.map((s) => s.address).join(', ')}`);
|
|
584
681
|
if (t.body) out.push('', 'Body:', t.body);
|
|
@@ -587,7 +684,7 @@ export async function runVerb(argv) {
|
|
|
587
684
|
if (card.comments.length) { out.push('', `Comments (${card.comments.length}):`); for (const c of card.comments) out.push(` [${ts(c.created_at)}] ${c.author}: ${c.body}`); }
|
|
588
685
|
// the card's history, without the board's own bookkeeping (a claim, a session kept or started, a goal ask, a
|
|
589
686
|
// message queued); `--all` shows every event, `--json` always does
|
|
590
|
-
const PLUMBING = new Set(['claimed', 'session_started', 'session_resumed', 'session_adopted', 'session_replaced', 'session_closed',
|
|
687
|
+
const PLUMBING = new Set(['claimed', 'session_started', 'session_resumed', 'session_adopted', 'agent_session_queued', 'session_replaced', 'session_closed',
|
|
591
688
|
'goal_set', 'goal_continued', 'decision_asked', 'board_message', 'board_message_sent', 'heartbeat', 'promoted', 'respawn_guarded',
|
|
592
689
|
'claim_extended', 'reclaimed', 'reconciled', 'status']);
|
|
593
690
|
const history = args.all ? card.events : card.events.filter((e) => !PLUMBING.has(e.kind));
|
|
@@ -600,7 +697,7 @@ export async function runVerb(argv) {
|
|
|
600
697
|
for (const r of card.runs) {
|
|
601
698
|
const s = r.metadata?.supercode;
|
|
602
699
|
out.push(` #${String(r.id).padEnd(3)} ${(r.outcome || r.status || 'active').padEnd(12)} @${r.profile || '-'} ${r.ended_at ? `${Math.max(0, r.ended_at - r.started_at)}s` : 'active'} ${ts(r.started_at)}`);
|
|
603
|
-
if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode}); observed ${executionOf(r).state}`);
|
|
700
|
+
if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode}); ${s.detached_at ? `handed to ${s.detached_to} at ${ts(s.detached_at)}` : `observed ${executionOf(r).state}`}`);
|
|
604
701
|
if (r.summary) out.push(` → ${r.summary.split('\n')[0].slice(0, 160)}`);
|
|
605
702
|
if (r.error) out.push(` ! ${r.error.split('\n')[0].slice(0, 160)}`);
|
|
606
703
|
}
|
|
@@ -746,6 +843,14 @@ export async function runVerb(argv) {
|
|
|
746
843
|
current().out(got.length ? `${got.map((a) => `#${a.id} ${a.filename} ${a.size} bytes ${ts(a.created_at)} ${a.stored_path}`).join('\n')}\n` : `(no attachments on ${id})\n`);
|
|
747
844
|
return 0;
|
|
748
845
|
}
|
|
846
|
+
case 'attachment-read': {
|
|
847
|
+
need(2, 'a card id and an attachment id');
|
|
848
|
+
const row = withBoard((b) => attachmentRecord(b.db, id, Number(rest[1])));
|
|
849
|
+
const range = await readAttachment(root, slug, row, { offset: args.offset === undefined ? 0 : Number(args.offset),
|
|
850
|
+
length: args.length === undefined ? undefined : Number(args.length), version: args.version ?? null });
|
|
851
|
+
current().out(`${JSON.stringify(range)}\n`);
|
|
852
|
+
return 0;
|
|
853
|
+
}
|
|
749
854
|
case 'attach-rm': {
|
|
750
855
|
need(1, 'an attachment id');
|
|
751
856
|
const row = withBoard((b) => engine.removeAttachment(b, Number(id)));
|
|
@@ -756,7 +861,7 @@ export async function runVerb(argv) {
|
|
|
756
861
|
case 'complete': {
|
|
757
862
|
need(1, 'a card id');
|
|
758
863
|
const metadata = metadataOf(args.metadata);
|
|
759
|
-
for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null,
|
|
864
|
+
for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
|
|
760
865
|
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
761
866
|
}
|
|
762
867
|
case 'edit': {
|
|
@@ -786,7 +891,7 @@ export async function runVerb(argv) {
|
|
|
786
891
|
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
787
892
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
788
893
|
}
|
|
789
|
-
case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null,
|
|
894
|
+
case 'request-review': need(1, 'a card id'); 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, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
790
895
|
case 'request-changes': {
|
|
791
896
|
need(2, 'a card id and the reason');
|
|
792
897
|
// each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
|
|
@@ -993,16 +1098,24 @@ export async function runVerb(argv) {
|
|
|
993
1098
|
const reports = await plan({ root, lanes, settings });
|
|
994
1099
|
const out = { capacity: reports.capacity, would: reports.flatMap((r) => r.would.map((w) => w.id)), waiting: reports.flatMap((r) => r.queued.map(([id, why]) => ({ id, why }))) };
|
|
995
1100
|
if (args.json) { say(args, '', out); return 0; }
|
|
1101
|
+
// each card as a person reads it, by the number its own board gave it
|
|
1102
|
+
const shown = [...out.would, ...out.waiting.map((w) => w.id), ...out.capacity.flatMap((cap) => cap.groups.flatMap((g) => g.cards))];
|
|
1103
|
+
const labels = new Map();
|
|
1104
|
+
for (const board of listBoards(root)) {
|
|
1105
|
+
const b = openBoard(root, board);
|
|
1106
|
+
try { for (const [cardId, n] of view(b, (db) => engine.numbersOf(db, shown))) labels.set(cardId, engine.cardLabel(n, cardId)); } finally { b.close(); }
|
|
1107
|
+
}
|
|
1108
|
+
const label = (cardId) => labels.get(cardId) ?? cardId;
|
|
996
1109
|
const lines = [];
|
|
997
1110
|
for (const cap of out.capacity) {
|
|
998
1111
|
const filter = Object.entries(cap.where ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
|
|
999
1112
|
const except = Object.entries(cap.unless ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
|
|
1000
1113
|
lines.push(`cap "${cap.name}": ${cap.counts} per ${cap.scope}${filter ? `, where ${filter}` : ''}${except ? `, unless ${except}` : ''}, max ${cap.max}`);
|
|
1001
1114
|
if (!cap.groups.length) lines.push(' (nothing counted)');
|
|
1002
|
-
for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].join(' ')}`);
|
|
1115
|
+
for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].map(label).join(', ')}`);
|
|
1003
1116
|
}
|
|
1004
|
-
lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
|
|
1005
|
-
for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
|
|
1117
|
+
lines.push(out.would.length ? `would start: ${out.would.map(label).join(', ')}` : 'would start: nothing');
|
|
1118
|
+
for (const w of out.waiting) lines.push(`${label(w.id)} ${w.why}`);
|
|
1006
1119
|
current().out(`${lines.join('\n')}\n`);
|
|
1007
1120
|
return 0;
|
|
1008
1121
|
}
|
|
@@ -1120,6 +1233,17 @@ export async function runVerb(argv) {
|
|
|
1120
1233
|
const session = await withBoardAsync(b => startCard(b, id, { lanes, settings, roles: rolesFor(b.db, id, sid, workflowFor(root).params) }));
|
|
1121
1234
|
say(args, `${id}: ${session?.address ?? 'started'}`, { ok: true, session }); return 0;
|
|
1122
1235
|
}
|
|
1236
|
+
case 'detach': {
|
|
1237
|
+
need(1, 'a card id');
|
|
1238
|
+
const person = args.to ?? (principalOf()?.startsWith('person:') ? principalOf() : null);
|
|
1239
|
+
const detached = withBoard(b => {
|
|
1240
|
+
const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
|
|
1241
|
+
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.');
|
|
1242
|
+
return detachSession(b, id, person);
|
|
1243
|
+
});
|
|
1244
|
+
say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
|
|
1245
|
+
return 0;
|
|
1246
|
+
}
|
|
1123
1247
|
case 'adopt': {
|
|
1124
1248
|
need(2, 'a card id and a session');
|
|
1125
1249
|
withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
@@ -1161,7 +1285,7 @@ export async function runVerb(argv) {
|
|
|
1161
1285
|
if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
|
|
1162
1286
|
need(1, 'a card id');
|
|
1163
1287
|
const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
|
|
1164
|
-
const payload = { ...fields,
|
|
1288
|
+
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) };
|
|
1165
1289
|
const results = [];
|
|
1166
1290
|
if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
|
|
1167
1291
|
else for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
|
|
@@ -1426,7 +1550,7 @@ function ownCards(db, sessionId) {
|
|
|
1426
1550
|
return { worked, held };
|
|
1427
1551
|
}
|
|
1428
1552
|
|
|
1429
|
-
const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', '
|
|
1553
|
+
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']);
|
|
1430
1554
|
|
|
1431
1555
|
/** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
|
|
1432
1556
|
* daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
|
|
@@ -1479,10 +1603,11 @@ function rolesFor(db, cardId, sessionId, params = {}) {
|
|
|
1479
1603
|
* subtasks, and the subtasks of the arcs it is a side session of, and those arcs themselves only to comment on. What it
|
|
1480
1604
|
* may do to them is the workflow's (rolesFor).
|
|
1481
1605
|
*/
|
|
1482
|
-
function authorize(db, verb, rest, args, own) {
|
|
1606
|
+
function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
|
|
1483
1607
|
const cards = [...new Set([...own.worked, ...own.side])];
|
|
1484
1608
|
const named = cards.join(', ');
|
|
1485
1609
|
const refuse = (why) => { throw new Error(`the session working ${named} ${why}`); };
|
|
1610
|
+
if (verb === 'serve' && copyServe) return;
|
|
1486
1611
|
if (verb === 'create') {
|
|
1487
1612
|
if (!own.worked.has(args['subtask-of']) && !own.side.has(args['subtask-of'])) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`);
|
|
1488
1613
|
return;
|
|
@@ -1490,7 +1615,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1490
1615
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
1491
1616
|
// 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
|
|
1492
1617
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1493
|
-
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'release', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
1618
|
+
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
1494
1619
|
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");
|
|
1495
1620
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1496
1621
|
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
@@ -1554,10 +1679,11 @@ function setDispatching(root, slug, on, reason) {
|
|
|
1554
1679
|
|
|
1555
1680
|
/**
|
|
1556
1681
|
* The owner's door: every card verb on this home's boards, from the CLI and the machine daemon's board door, answered
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
1682
|
+
* in a call of its own (call.mjs), with the home this process holds. The socket is served on a thread of its own
|
|
1683
|
+
* (owner-door.mjs): a listing is read there, and every other verb is answered here, by the board's one writer. One owner
|
|
1684
|
+
* per home, by an OS lock held for its whole life by a holder process of its own (an exclusive SQLite lock on
|
|
1685
|
+
* `<home>/board.lock`, released when the owner ends and the holder's standard input closes): a second serve refuses,
|
|
1686
|
+
* and only the lock's owner removes and binds the socket.
|
|
1561
1687
|
*/
|
|
1562
1688
|
async function answerVerbs(root, log) {
|
|
1563
1689
|
const home = boardHome(root);
|
|
@@ -1577,59 +1703,51 @@ async function answerVerbs(root, log) {
|
|
|
1577
1703
|
log(`owner epoch ${epoch}`);
|
|
1578
1704
|
const path = boardOwnerSocket(root);
|
|
1579
1705
|
try { unlinkSync(path); } catch { /* none left */ }
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
socket.setEncoding('utf8');
|
|
1584
|
-
socket.on('error', () => { /* the caller went away */ });
|
|
1585
|
-
socket.on('data', (chunk) => {
|
|
1586
|
-
if (input === null) return;
|
|
1587
|
-
input += chunk;
|
|
1588
|
-
const end = input.indexOf('\n');
|
|
1589
|
-
if (end < 0) return;
|
|
1590
|
-
const line = input.slice(0, end);
|
|
1591
|
-
input = null;
|
|
1592
|
-
// one answer per call, whatever the request did: a request that fails is a failed answer, never the owner's end
|
|
1593
|
-
answerOne(root, line)
|
|
1594
|
-
.catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
|
|
1595
|
-
.then((answer) => writeOwnerAnswer(socket, answer))
|
|
1596
|
-
.catch(() => { socket.destroy(); }); // a disconnected caller leaves no queued reply
|
|
1597
|
-
});
|
|
1598
|
-
});
|
|
1599
|
-
await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
|
|
1600
|
-
try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
|
|
1706
|
+
let closeDoor;
|
|
1707
|
+
try { closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log }); }
|
|
1708
|
+
catch (error) { lock.release(); throw error; }
|
|
1601
1709
|
log(`answering the card verbs of ${root} at ${path}`);
|
|
1602
|
-
return () => {
|
|
1710
|
+
return () => { closeDoor(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
|
|
1603
1711
|
}
|
|
1604
1712
|
|
|
1605
|
-
/** One request
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1713
|
+
/** One request's own steps and the separate process activity during its wait.
|
|
1714
|
+
* Show/list/comment/create also retain receipts below the slow-log threshold;
|
|
1715
|
+
* absence from a slow-only log cannot prove those calls met the time bound. */
|
|
1716
|
+
export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
1717
|
+
const scoped = typeof slowLog.withStepScope === 'function';
|
|
1718
|
+
const run = async () => {
|
|
1719
|
+
const began = performance.now(), before = slowLog.processCounters?.() ?? null;
|
|
1720
|
+
let named = null, by = null, answer = null, outcome = 'error';
|
|
1721
|
+
try {
|
|
1722
|
+
answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
|
|
1723
|
+
outcome = answer?.code === 0 ? 'ok' : `exit ${answer?.code ?? 'unknown'}`;
|
|
1724
|
+
return answer;
|
|
1725
|
+
} finally {
|
|
1726
|
+
const ended = performance.now(), ms = ended - began;
|
|
1727
|
+
// a listing the read door leaves to the owner is recorded there, as the owner answers it
|
|
1728
|
+
if (named && !answer?.byOwner) {
|
|
1729
|
+
const overlap = slowLog.stepsWithin?.(began, ended) ?? null;
|
|
1730
|
+
const used = ms > slowLog.SLOW_MS && before ? slowLog.processUse(before) : null;
|
|
1731
|
+
recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, outcome, slowest: null, force: answer?.code !== 0,
|
|
1732
|
+
includeFast: ['show', 'list', 'comment', 'create'].includes(named),
|
|
1733
|
+
extra: { caller: by, code: answer?.code ?? null, response: { code: answer?.code ?? null, stderr: redactText([].concat(answer?.stderr ?? []).join('')).slice(0, 2000) }, step_scope: scoped ? 'request' : 'process_interval',
|
|
1734
|
+
steps: scoped ? takeSteps() : overlap, ...(scoped ? { overlap_steps: overlap } : {}), ...(used ? { whole_process: used } : {}) } });
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
return scoped ? slowLog.withStepScope(run) : run();
|
|
1624
1739
|
}
|
|
1625
1740
|
|
|
1626
|
-
|
|
1741
|
+
/** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
|
|
1742
|
+
* one the owner is to answer (listing.mjs byOwner). */
|
|
1743
|
+
async function answerRequest(root, line, naming, { readOnly = false } = {}) {
|
|
1627
1744
|
try {
|
|
1628
1745
|
const request = JSON.parse(line);
|
|
1629
1746
|
const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
|
|
1630
1747
|
const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
|
|
1631
1748
|
if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
|
|
1632
1749
|
const verb = parse(argv)._[0];
|
|
1750
|
+
if (readOnly && (tool || !['list', 'ls'].includes(verb))) return { byOwner: true };
|
|
1633
1751
|
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` };
|
|
1634
1752
|
const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
|
|
1635
1753
|
const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
|
|
@@ -1642,13 +1760,17 @@ async function answerRequest(root, line, naming) {
|
|
|
1642
1760
|
const stdin = typeof request.stdin === 'string' ? request.stdin : null;
|
|
1643
1761
|
const stdout = [];
|
|
1644
1762
|
const stderr = [];
|
|
1645
|
-
let code;
|
|
1646
|
-
await within({ caller, facts, cwd, stdin, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
|
|
1763
|
+
let code, byOwner = false;
|
|
1764
|
+
await within({ caller, facts, cwd, stdin, readOnly, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
|
|
1647
1765
|
// a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
|
|
1648
1766
|
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; }
|
|
1649
1767
|
try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
|
|
1650
|
-
catch (error) {
|
|
1768
|
+
catch (error) {
|
|
1769
|
+
if (readOnly && error?.code === READ_BY_OWNER) { byOwner = true; return; }
|
|
1770
|
+
stderr.push(`supercode workflow: ${error?.message ?? error}\n`); code = 1;
|
|
1771
|
+
}
|
|
1651
1772
|
});
|
|
1773
|
+
if (byOwner) return { byOwner: true };
|
|
1652
1774
|
return { code: code ?? 0, stdout, stderr };
|
|
1653
1775
|
} catch (error) {
|
|
1654
1776
|
return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
|
|
@@ -1663,8 +1785,9 @@ async function serve(root, args = {}) {
|
|
|
1663
1785
|
if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
|
|
1664
1786
|
let stopped = false;
|
|
1665
1787
|
let events;
|
|
1788
|
+
let fleetListeners = null, reloadListeners = () => {}, listenerRevision = 0, listenerBootstrapped = false;
|
|
1666
1789
|
// a signal ends the round in progress with nothing new started (dispatch.mjs stopDispatching) and no round after it
|
|
1667
|
-
const stop = () => { stopped = true; stopDispatching(); events?.close(); };
|
|
1790
|
+
const stop = () => { stopped = true; stopDispatching(); events?.close(); fleetListeners?.suspend(); };
|
|
1668
1791
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, stop);
|
|
1669
1792
|
// what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
|
|
1670
1793
|
const kept = join(root, 'logs', 'workflow-serve.log');
|
|
@@ -1727,7 +1850,7 @@ async function serve(root, args = {}) {
|
|
|
1727
1850
|
// the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
|
|
1728
1851
|
// watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
|
|
1729
1852
|
const unfollowHome = followHome(root, {
|
|
1730
|
-
onChange: (name) => log(`home changed (${name}): read again on its next use`),
|
|
1853
|
+
onChange: (name) => { log(`home changed (${name}): read again on its next use`); listenerRevision++; listenerBootstrapped = false; fleetListeners?.suspend(); reloadListeners(); },
|
|
1731
1854
|
onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
|
|
1732
1855
|
});
|
|
1733
1856
|
// Hermes's install is located once per process (a launch's memory snapshot reads it): here, before any verb is
|
|
@@ -1767,7 +1890,7 @@ async function serve(root, args = {}) {
|
|
|
1767
1890
|
} });
|
|
1768
1891
|
// a machine's panes changing in its record (a session came up under its pane, a pane or its session ended) is what a
|
|
1769
1892
|
// round binds a launch, verifies an adoption and reads a session's end from: the next round is due then
|
|
1770
|
-
onPanesChanged(() => events?.wake('machine-record'));
|
|
1893
|
+
const unfollowPanes = onPanesChanged(() => events?.wake('machine-record'));
|
|
1771
1894
|
// the owner holds its boards only now, its watch installed (watch before load): opened once, changed by events
|
|
1772
1895
|
holdBoards(root);
|
|
1773
1896
|
// a pause of the collector holds the loop like any synchronous step: each is a step, so a block line names it
|
|
@@ -1789,6 +1912,58 @@ async function serve(root, args = {}) {
|
|
|
1789
1912
|
};
|
|
1790
1913
|
await registerOwner();
|
|
1791
1914
|
onLocalReopen(registerOwner);
|
|
1915
|
+
let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
|
|
1916
|
+
const listenerErrors = new Set();
|
|
1917
|
+
const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
|
|
1918
|
+
const scanListeners = ({ waiting = true } = {}) => listenerBootstrapped && fleetRecordsCurrent() && ownCall(() => observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting }));
|
|
1919
|
+
const readListenerRecords = () => {
|
|
1920
|
+
if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
|
|
1921
|
+
const revision = listenerRevision;
|
|
1922
|
+
listenerReading = readFleetRecords().then(records => {
|
|
1923
|
+
if (revision !== listenerRevision || !fleetListeners.enabled || stopped) return;
|
|
1924
|
+
listenerRecords = records;
|
|
1925
|
+
if (!listenerBootstrapped) { fleetListeners.bootstrap(records); listenerBootstrapped = true; scanListeners(); }
|
|
1926
|
+
})
|
|
1927
|
+
.catch(error => { listenerRecords = {}; listenerLog(`fleet listener records unread: ${String(error.message).slice(0, 300)}`); })
|
|
1928
|
+
.finally(() => { listenerReading = null; });
|
|
1929
|
+
return listenerReading;
|
|
1930
|
+
};
|
|
1931
|
+
fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
|
|
1932
|
+
file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
|
|
1933
|
+
enrich: event => ownCall(() => enrichFleetEvent(root, event)),
|
|
1934
|
+
retry: () => scanListeners({ waiting: false }),
|
|
1935
|
+
});
|
|
1936
|
+
const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
|
|
1937
|
+
reloadListeners = () => {
|
|
1938
|
+
listenerReloadDue = true;
|
|
1939
|
+
if (listenerReloading || stopped) return listenerReloading;
|
|
1940
|
+
listenerReloading = (async () => {
|
|
1941
|
+
while (listenerReloadDue && !stopped) {
|
|
1942
|
+
listenerReloadDue = false;
|
|
1943
|
+
const revision = listenerRevision;
|
|
1944
|
+
try {
|
|
1945
|
+
const orchestration = await ownCall(() => readHome(root));
|
|
1946
|
+
if (revision !== listenerRevision || stopped) continue;
|
|
1947
|
+
fleetListeners.configure(orchestration.workflow?.listeners ?? {});
|
|
1948
|
+
if (fleetListeners.enabled) { scanListeners(); void readListenerRecords(); }
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
fleetListeners.suspend();
|
|
1951
|
+
listenerLog(`fleet listener configuration unread; retained obligations wait: ${String(error.message).slice(0, 300)}`);
|
|
1952
|
+
// A failed source read is retried by the next round; a failed home read also retains no stale intent.
|
|
1953
|
+
listenerReloadDue = true; break;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
})().finally(() => { listenerReloading = null; });
|
|
1957
|
+
return listenerReloading;
|
|
1958
|
+
};
|
|
1959
|
+
await reloadListeners();
|
|
1960
|
+
// Retry a failed home decode without waiting for a dispatch sweep. Reading the held stream also enforces its
|
|
1961
|
+
// heartbeat deadline and reconnects after local caller loss, even when every board's dispatching is off.
|
|
1962
|
+
let nextListenerRead = 0;
|
|
1963
|
+
const listenerTimer = setInterval(() => {
|
|
1964
|
+
if (listenerReloadDue) void reloadListeners();
|
|
1965
|
+
if (Date.now() >= nextListenerRead) { nextListenerRead = Date.now() + 5000; void readListenerRecords(); }
|
|
1966
|
+
}, 2000); listenerTimer.unref?.();
|
|
1792
1967
|
log('dispatch wakes on board writes, document edits, board mail and a change of a machine\'s panes in its record; the interval sweep reads each machine\'s own door');
|
|
1793
1968
|
const stuck = {};
|
|
1794
1969
|
const warned = {};
|
|
@@ -1798,6 +1973,8 @@ async function serve(root, args = {}) {
|
|
|
1798
1973
|
let wakeReason = null;
|
|
1799
1974
|
try {
|
|
1800
1975
|
while (!stopped) {
|
|
1976
|
+
if (listenerReloadDue) void reloadListeners();
|
|
1977
|
+
if (fleetListeners.enabled) scanListeners();
|
|
1801
1978
|
// A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
|
|
1802
1979
|
// starts the new one. Nothing waits for an installation; a second round, old or new, is harmless.
|
|
1803
1980
|
const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
|
|
@@ -1866,6 +2043,10 @@ async function serve(root, args = {}) {
|
|
|
1866
2043
|
if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1867
2044
|
}
|
|
1868
2045
|
} finally {
|
|
2046
|
+
fleetListeners.suspend(); clearInterval(listenerTimer); unfollowRecords(); unfollowPanes();
|
|
2047
|
+
await listenerReloading;
|
|
2048
|
+
await listenerReading;
|
|
2049
|
+
try { await fleetListeners.close(); } catch (error) { log(`fleet listener shutdown: retained filing outcome unknown (${String(error.message).slice(0, 300)})`); }
|
|
1869
2050
|
// a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
|
|
1870
2051
|
try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
|
|
1871
2052
|
unwatchLoop?.(); releaseHeldBoards(); closeVerbs();
|