@volter/supercode-orchestrator 0.5.204 → 0.5.206
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/apply/doors.mjs +25 -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 +225 -87
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +297 -75
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +78 -24
- 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 +5 -0
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -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 +55 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +91 -28
- package/board/ztrack.mjs +87 -21
- 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
|
|
|
@@ -94,18 +99,19 @@ The board's verbs, with their flags:
|
|
|
94
99
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
95
100
|
create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
96
101
|
[--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]
|
|
102
|
+
[--model] [--provider] [--reviewer PROFILE] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
98
103
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
99
104
|
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]
|
|
105
|
+
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
106
|
reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
|
|
102
107
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
103
108
|
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
|
-
|
|
109
|
+
attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
|
|
110
|
+
complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
|
|
105
111
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
106
112
|
pause <id> --reason <why> resume <id> cancel <id> --reason <why>
|
|
107
113
|
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]
|
|
114
|
+
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
115
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
110
116
|
specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
111
117
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
@@ -131,7 +137,7 @@ And:
|
|
|
131
137
|
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
132
138
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
133
139
|
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
|
|
140
|
+
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
141
|
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
136
142
|
notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
|
|
137
143
|
notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
|
|
@@ -148,6 +154,7 @@ And:
|
|
|
148
154
|
request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
|
|
149
155
|
request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
|
|
150
156
|
complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
|
|
157
|
+
listeners status [--json]: declared role fleet listeners, retained filing counts, source cursors and gaps.
|
|
151
158
|
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
152
159
|
heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. A side session
|
|
153
160
|
(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 +262,40 @@ const callerName = () => current().facts.HERMES_PROFILE || current().facts.USER
|
|
|
255
262
|
* automation running it, its own plain session, or the person its Teams login names. Never the OS user; null when
|
|
256
263
|
* nothing names one.
|
|
257
264
|
*/
|
|
258
|
-
async function
|
|
265
|
+
async function ownerStep(name, fn) {
|
|
266
|
+
const began = performance.now();
|
|
267
|
+
try { return await fn(); }
|
|
268
|
+
finally { noteStep(`owner preparation: ${name}`, performance.now() - began); }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function recordedRunAgent(db, sid, address) {
|
|
272
|
+
const rows = db.prepare(CALLER_RUN_AGENT).all(sid, address);
|
|
273
|
+
const owners = new Map();
|
|
274
|
+
for (const row of rows) {
|
|
275
|
+
if (!/^agent_[0-9a-f]{32}$/.test(row.agent_id) || typeof row.org !== 'string' || !row.org) continue;
|
|
276
|
+
owners.set(JSON.stringify([row.org, row.agent_id]), row);
|
|
277
|
+
}
|
|
278
|
+
if (owners.size > 1) throw new Error('recorded native session has conflicting agent ownership');
|
|
279
|
+
return owners.values().next().value ?? null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function callerPrincipal(root, sid, { teams = false, recordedAgent = null } = {}) {
|
|
259
283
|
const facts = current().facts;
|
|
284
|
+
if (recordedAgent && facts.SUPERCODE_AGENT_ID && facts.SUPERCODE_AGENT_ID !== recordedAgent.agent_id)
|
|
285
|
+
throw new Error('native session ownership does not match its launch identity');
|
|
286
|
+
if (recordedAgent && facts.SUPERCODE_AGENT_ORG && facts.SUPERCODE_AGENT_ORG !== recordedAgent.org)
|
|
287
|
+
throw new Error('native session Org does not match its launch identity');
|
|
260
288
|
if (facts.SUPERCODE_AGENT_ID) return `agent:${facts.SUPERCODE_AGENT_ID}`;
|
|
261
289
|
if (facts.SUPERCODE_AUTOMATION) return `automation:${facts.SUPERCODE_AUTOMATION}`;
|
|
262
290
|
if (sid) {
|
|
263
291
|
const harness = boardCaller()?.harness ?? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code');
|
|
264
|
-
const owner = agentRunForSession(root, [`${harness}:${sid}`]);
|
|
265
|
-
if (owner
|
|
292
|
+
const owner = await ownerStep('local agent ownership', () => agentRunForSession(root, [`${harness}:${sid}`]));
|
|
293
|
+
if (owner && recordedAgent && (owner.agent_id !== recordedAgent.agent_id || owner.org !== recordedAgent.org))
|
|
294
|
+
throw new Error('local and recorded native session ownership conflict');
|
|
295
|
+
if (owner || recordedAgent) return `agent:${(owner ?? recordedAgent).agent_id}`;
|
|
266
296
|
// a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
|
|
267
297
|
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 */ }
|
|
298
|
+
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
299
|
return declared ? `agent:${declared}` : `session:${callerAddress()}`;
|
|
270
300
|
}
|
|
271
301
|
if (!teams) return null;
|
|
@@ -299,14 +329,15 @@ const ts = (t) => {
|
|
|
299
329
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
300
330
|
};
|
|
301
331
|
// 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}` : ''}`;
|
|
332
|
+
const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(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
333
|
const cardDict = (t) => ({
|
|
304
334
|
id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
305
335
|
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
336
|
created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
307
337
|
skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
|
|
308
338
|
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,
|
|
339
|
+
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null, detached: t.detached ?? null,
|
|
340
|
+
reviewer: t.reviewer ?? null,
|
|
310
341
|
});
|
|
311
342
|
|
|
312
343
|
/** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
|
|
@@ -337,12 +368,20 @@ const metadataOf = (v) => {
|
|
|
337
368
|
|
|
338
369
|
/** 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
370
|
export async function runVerb(argv) {
|
|
340
|
-
await knowLocalMachine();
|
|
341
371
|
const args = parse(argv);
|
|
372
|
+
assertSourceReceipt(args);
|
|
373
|
+
await ownerStep('machine identity', () => knowLocalMachine());
|
|
342
374
|
const [verb, ...rest] = args._;
|
|
343
375
|
if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
|
|
344
376
|
const root = homeOf(args);
|
|
345
377
|
if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
|
|
378
|
+
if (verb === 'listeners') {
|
|
379
|
+
if (rest.length && rest[0] !== 'status') throw new Error('workflow listeners accepts status; declarations live in workflow.yaml listeners');
|
|
380
|
+
const orchestration = await readHome(root);
|
|
381
|
+
const status = { declarations: orchestration.workflow?.listeners ?? {}, ...fleetListenerStatus(root) };
|
|
382
|
+
say(args, JSON.stringify(status, null, 2), status);
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
346
385
|
// 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
386
|
// the cards it shows, and a card filed through it carries that tag.
|
|
348
387
|
const named = boardOf(root, args);
|
|
@@ -357,20 +396,35 @@ export async function runVerb(argv) {
|
|
|
357
396
|
|
|
358
397
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
359
398
|
const listing = ['list','ls'].includes(verb);
|
|
360
|
-
|
|
399
|
+
const readingDispatching = verb === 'dispatching' && rest.length === 0;
|
|
400
|
+
const reading = READS.has(verb) || readingDispatching;
|
|
401
|
+
// a listing on the owner's read door reads the workflow the owner holds (owner-door.mjs), and names no session: it
|
|
402
|
+
// records nothing, and `--mine` is the caller's profile
|
|
403
|
+
const readDoor = Boolean(current().readOnly);
|
|
404
|
+
if (!readDoor) await ownerStep('workflow', () => loadWorkflow(root)).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
361
405
|
// 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();
|
|
406
|
+
if (!listing && !readingDispatching) await ownerStep('manager main sessions', () => readAgentMains(root));
|
|
407
|
+
const sid = listing && (!args.mine || readDoor) ? null : callerSession();
|
|
364
408
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
365
409
|
current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
|
|
366
410
|
if (sid) setActor(callerAddress());
|
|
367
|
-
|
|
411
|
+
const recordedAgent = sid && boardExists(root, slug)
|
|
412
|
+
? await ownerStep('committed native agent ownership', () => withBoard((b) => recordedRunAgent(b.db, sid, callerAddress()))) : null;
|
|
413
|
+
setPrincipal(await ownerStep('caller principal', () => callerPrincipal(root, sid, { teams: !sid && !reading, recordedAgent })));
|
|
368
414
|
// the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
|
|
369
415
|
if (!current().said.has(`dispatcher:${slug}`)) { current().said.add(`dispatcher:${slug}`); for (const line of dispatcherNotice(slug)) current().err(`${line}\n`); }
|
|
370
|
-
if (sid && !
|
|
416
|
+
if (sid && !reading && boardExists(root, slug)) {
|
|
371
417
|
// binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
|
|
372
418
|
// never writes)
|
|
373
|
-
await withBoardAsync(async (b) => {
|
|
419
|
+
await withBoardAsync(async (b) => {
|
|
420
|
+
if (isCallersCall()) await reconcileCallerLaunch(b, sid);
|
|
421
|
+
const own = { ...ownCards(b.db, sid), side: sideArcs(b.db, sid) };
|
|
422
|
+
if (!own.worked.size && !own.side.size) return;
|
|
423
|
+
// A copied home owns no live sessions or manager addresses. Its owner may
|
|
424
|
+
// answer local verbs while every board's dispatching remains off.
|
|
425
|
+
const copyServe = verb === 'serve' && Boolean(boardCopyOf(b)) && listBoards(root).every(board => !dispatchingOf(root, board).on);
|
|
426
|
+
authorize(b.db, verb, rest, args, own, { copyServe });
|
|
427
|
+
});
|
|
374
428
|
}
|
|
375
429
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
376
430
|
// every verb reads the board through its workflow (a list settles cards too), so every verb loads it
|
|
@@ -466,6 +520,7 @@ export async function runVerb(argv) {
|
|
|
466
520
|
const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
|
|
467
521
|
const created = await withBoardAsync(async (b) => engine.createCard(b, await engine.prepareCard(b, {
|
|
468
522
|
title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
523
|
+
reviewer: args.reviewer ?? null,
|
|
469
524
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
470
525
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
471
526
|
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? null,
|
|
@@ -543,7 +598,12 @@ export async function runVerb(argv) {
|
|
|
543
598
|
got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
|
|
544
599
|
got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit, launchWindowSeconds: settings.launchWindow });
|
|
545
600
|
got.stage = statusOf(b.db, got.task);
|
|
546
|
-
|
|
601
|
+
const entry = workflowFor(root);
|
|
602
|
+
// A workflow may explicitly replace the built-in time field. Keep its
|
|
603
|
+
// full derived scope; ordinary totals need only this card's runs.
|
|
604
|
+
got.time = Object.hasOwn(entry.workflow.derived ?? {}, 'time')
|
|
605
|
+
? scopeOf(b.db, b, got.task, { params: entry.params }).card.time
|
|
606
|
+
: timeOf(b.db, got.task);
|
|
547
607
|
return got;
|
|
548
608
|
});
|
|
549
609
|
const t = card.task;
|
|
@@ -554,6 +614,8 @@ export async function runVerb(argv) {
|
|
|
554
614
|
}
|
|
555
615
|
const out = [`Task ${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
616
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
617
|
+
if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
|
|
618
|
+
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
619
|
// where the card's time went: each role's runs, and the rest waiting
|
|
558
620
|
const mins = (sec) => `${Math.round(sec / 60)}m`;
|
|
559
621
|
out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
|
|
@@ -587,7 +649,7 @@ export async function runVerb(argv) {
|
|
|
587
649
|
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
650
|
// the card's history, without the board's own bookkeeping (a claim, a session kept or started, a goal ask, a
|
|
589
651
|
// 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',
|
|
652
|
+
const PLUMBING = new Set(['claimed', 'session_started', 'session_resumed', 'session_adopted', 'agent_session_queued', 'session_replaced', 'session_closed',
|
|
591
653
|
'goal_set', 'goal_continued', 'decision_asked', 'board_message', 'board_message_sent', 'heartbeat', 'promoted', 'respawn_guarded',
|
|
592
654
|
'claim_extended', 'reclaimed', 'reconciled', 'status']);
|
|
593
655
|
const history = args.all ? card.events : card.events.filter((e) => !PLUMBING.has(e.kind));
|
|
@@ -600,7 +662,7 @@ export async function runVerb(argv) {
|
|
|
600
662
|
for (const r of card.runs) {
|
|
601
663
|
const s = r.metadata?.supercode;
|
|
602
664
|
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}`);
|
|
665
|
+
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
666
|
if (r.summary) out.push(` → ${r.summary.split('\n')[0].slice(0, 160)}`);
|
|
605
667
|
if (r.error) out.push(` ! ${r.error.split('\n')[0].slice(0, 160)}`);
|
|
606
668
|
}
|
|
@@ -746,6 +808,14 @@ export async function runVerb(argv) {
|
|
|
746
808
|
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
809
|
return 0;
|
|
748
810
|
}
|
|
811
|
+
case 'attachment-read': {
|
|
812
|
+
need(2, 'a card id and an attachment id');
|
|
813
|
+
const row = withBoard((b) => attachmentRecord(b.db, id, Number(rest[1])));
|
|
814
|
+
const range = await readAttachment(root, slug, row, { offset: args.offset === undefined ? 0 : Number(args.offset),
|
|
815
|
+
length: args.length === undefined ? undefined : Number(args.length), version: args.version ?? null });
|
|
816
|
+
current().out(`${JSON.stringify(range)}\n`);
|
|
817
|
+
return 0;
|
|
818
|
+
}
|
|
749
819
|
case 'attach-rm': {
|
|
750
820
|
need(1, 'an attachment id');
|
|
751
821
|
const row = withBoard((b) => engine.removeAttachment(b, Number(id)));
|
|
@@ -756,7 +826,7 @@ export async function runVerb(argv) {
|
|
|
756
826
|
case 'complete': {
|
|
757
827
|
need(1, 'a card id');
|
|
758
828
|
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,
|
|
829
|
+
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
830
|
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
761
831
|
}
|
|
762
832
|
case 'edit': {
|
|
@@ -786,7 +856,7 @@ export async function runVerb(argv) {
|
|
|
786
856
|
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
787
857
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
788
858
|
}
|
|
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,
|
|
859
|
+
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
860
|
case 'request-changes': {
|
|
791
861
|
need(2, 'a card id and the reason');
|
|
792
862
|
// each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
|
|
@@ -1120,6 +1190,17 @@ export async function runVerb(argv) {
|
|
|
1120
1190
|
const session = await withBoardAsync(b => startCard(b, id, { lanes, settings, roles: rolesFor(b.db, id, sid, workflowFor(root).params) }));
|
|
1121
1191
|
say(args, `${id}: ${session?.address ?? 'started'}`, { ok: true, session }); return 0;
|
|
1122
1192
|
}
|
|
1193
|
+
case 'detach': {
|
|
1194
|
+
need(1, 'a card id');
|
|
1195
|
+
const person = args.to ?? (principalOf()?.startsWith('person:') ? principalOf() : null);
|
|
1196
|
+
const detached = withBoard(b => {
|
|
1197
|
+
const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
|
|
1198
|
+
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.');
|
|
1199
|
+
return detachSession(b, id, person);
|
|
1200
|
+
});
|
|
1201
|
+
say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
|
|
1202
|
+
return 0;
|
|
1203
|
+
}
|
|
1123
1204
|
case 'adopt': {
|
|
1124
1205
|
need(2, 'a card id and a session');
|
|
1125
1206
|
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 +1242,7 @@ export async function runVerb(argv) {
|
|
|
1161
1242
|
if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
|
|
1162
1243
|
need(1, 'a card id');
|
|
1163
1244
|
const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
|
|
1164
|
-
const payload = { ...fields,
|
|
1245
|
+
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
1246
|
const results = [];
|
|
1166
1247
|
if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
|
|
1167
1248
|
else for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
|
|
@@ -1426,7 +1507,7 @@ function ownCards(db, sessionId) {
|
|
|
1426
1507
|
return { worked, held };
|
|
1427
1508
|
}
|
|
1428
1509
|
|
|
1429
|
-
const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', '
|
|
1510
|
+
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
1511
|
|
|
1431
1512
|
/** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
|
|
1432
1513
|
* daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
|
|
@@ -1479,10 +1560,11 @@ function rolesFor(db, cardId, sessionId, params = {}) {
|
|
|
1479
1560
|
* subtasks, and the subtasks of the arcs it is a side session of, and those arcs themselves only to comment on. What it
|
|
1480
1561
|
* may do to them is the workflow's (rolesFor).
|
|
1481
1562
|
*/
|
|
1482
|
-
function authorize(db, verb, rest, args, own) {
|
|
1563
|
+
function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
|
|
1483
1564
|
const cards = [...new Set([...own.worked, ...own.side])];
|
|
1484
1565
|
const named = cards.join(', ');
|
|
1485
1566
|
const refuse = (why) => { throw new Error(`the session working ${named} ${why}`); };
|
|
1567
|
+
if (verb === 'serve' && copyServe) return;
|
|
1486
1568
|
if (verb === 'create') {
|
|
1487
1569
|
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
1570
|
return;
|
|
@@ -1490,7 +1572,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1490
1572
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
1491
1573
|
// 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
1574
|
// (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`);
|
|
1575
|
+
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
1576
|
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
1577
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1496
1578
|
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
@@ -1554,10 +1636,11 @@ function setDispatching(root, slug, on, reason) {
|
|
|
1554
1636
|
|
|
1555
1637
|
/**
|
|
1556
1638
|
* 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
|
-
*
|
|
1639
|
+
* in a call of its own (call.mjs), with the home this process holds. The socket is served on a thread of its own
|
|
1640
|
+
* (owner-door.mjs): a listing is read there, and every other verb is answered here, by the board's one writer. One owner
|
|
1641
|
+
* per home, by an OS lock held for its whole life by a holder process of its own (an exclusive SQLite lock on
|
|
1642
|
+
* `<home>/board.lock`, released when the owner ends and the holder's standard input closes): a second serve refuses,
|
|
1643
|
+
* and only the lock's owner removes and binds the socket.
|
|
1561
1644
|
*/
|
|
1562
1645
|
async function answerVerbs(root, log) {
|
|
1563
1646
|
const home = boardHome(root);
|
|
@@ -1577,59 +1660,51 @@ async function answerVerbs(root, log) {
|
|
|
1577
1660
|
log(`owner epoch ${epoch}`);
|
|
1578
1661
|
const path = boardOwnerSocket(root);
|
|
1579
1662
|
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 */ }
|
|
1663
|
+
let closeDoor;
|
|
1664
|
+
try { closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log }); }
|
|
1665
|
+
catch (error) { lock.release(); throw error; }
|
|
1601
1666
|
log(`answering the card verbs of ${root} at ${path}`);
|
|
1602
|
-
return () => {
|
|
1667
|
+
return () => { closeDoor(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
|
|
1603
1668
|
}
|
|
1604
1669
|
|
|
1605
|
-
/** One request
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1670
|
+
/** One request's own steps and the separate process activity during its wait.
|
|
1671
|
+
* Show/list/comment/create also retain receipts below the slow-log threshold;
|
|
1672
|
+
* absence from a slow-only log cannot prove those calls met the time bound. */
|
|
1673
|
+
export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
1674
|
+
const scoped = typeof slowLog.withStepScope === 'function';
|
|
1675
|
+
const run = async () => {
|
|
1676
|
+
const began = performance.now(), before = slowLog.processCounters?.() ?? null;
|
|
1677
|
+
let named = null, by = null, answer = null, outcome = 'error';
|
|
1678
|
+
try {
|
|
1679
|
+
answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
|
|
1680
|
+
outcome = answer?.code === 0 ? 'ok' : `exit ${answer?.code ?? 'unknown'}`;
|
|
1681
|
+
return answer;
|
|
1682
|
+
} finally {
|
|
1683
|
+
const ended = performance.now(), ms = ended - began;
|
|
1684
|
+
// a listing the read door leaves to the owner is recorded there, as the owner answers it
|
|
1685
|
+
if (named && !answer?.byOwner) {
|
|
1686
|
+
const overlap = slowLog.stepsWithin?.(began, ended) ?? null;
|
|
1687
|
+
const used = ms > slowLog.SLOW_MS && before ? slowLog.processUse(before) : null;
|
|
1688
|
+
recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, outcome, slowest: null, force: answer?.code !== 0,
|
|
1689
|
+
includeFast: ['show', 'list', 'comment', 'create'].includes(named),
|
|
1690
|
+
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',
|
|
1691
|
+
steps: scoped ? takeSteps() : overlap, ...(scoped ? { overlap_steps: overlap } : {}), ...(used ? { whole_process: used } : {}) } });
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
return scoped ? slowLog.withStepScope(run) : run();
|
|
1624
1696
|
}
|
|
1625
1697
|
|
|
1626
|
-
|
|
1698
|
+
/** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
|
|
1699
|
+
* one the owner is to answer (listing.mjs byOwner). */
|
|
1700
|
+
async function answerRequest(root, line, naming, { readOnly = false } = {}) {
|
|
1627
1701
|
try {
|
|
1628
1702
|
const request = JSON.parse(line);
|
|
1629
1703
|
const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
|
|
1630
1704
|
const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
|
|
1631
1705
|
if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
|
|
1632
1706
|
const verb = parse(argv)._[0];
|
|
1707
|
+
if (readOnly && (tool || !['list', 'ls'].includes(verb))) return { byOwner: true };
|
|
1633
1708
|
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
1709
|
const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
|
|
1635
1710
|
const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
|
|
@@ -1642,13 +1717,17 @@ async function answerRequest(root, line, naming) {
|
|
|
1642
1717
|
const stdin = typeof request.stdin === 'string' ? request.stdin : null;
|
|
1643
1718
|
const stdout = [];
|
|
1644
1719
|
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 () => {
|
|
1720
|
+
let code, byOwner = false;
|
|
1721
|
+
await within({ caller, facts, cwd, stdin, readOnly, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
|
|
1647
1722
|
// a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
|
|
1648
1723
|
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
1724
|
try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
|
|
1650
|
-
catch (error) {
|
|
1725
|
+
catch (error) {
|
|
1726
|
+
if (readOnly && error?.code === READ_BY_OWNER) { byOwner = true; return; }
|
|
1727
|
+
stderr.push(`supercode workflow: ${error?.message ?? error}\n`); code = 1;
|
|
1728
|
+
}
|
|
1651
1729
|
});
|
|
1730
|
+
if (byOwner) return { byOwner: true };
|
|
1652
1731
|
return { code: code ?? 0, stdout, stderr };
|
|
1653
1732
|
} catch (error) {
|
|
1654
1733
|
return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
|
|
@@ -1663,8 +1742,9 @@ async function serve(root, args = {}) {
|
|
|
1663
1742
|
if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
|
|
1664
1743
|
let stopped = false;
|
|
1665
1744
|
let events;
|
|
1745
|
+
let fleetListeners = null, reloadListeners = () => {}, listenerRevision = 0, listenerBootstrapped = false;
|
|
1666
1746
|
// 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(); };
|
|
1747
|
+
const stop = () => { stopped = true; stopDispatching(); events?.close(); fleetListeners?.suspend(); };
|
|
1668
1748
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, stop);
|
|
1669
1749
|
// what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
|
|
1670
1750
|
const kept = join(root, 'logs', 'workflow-serve.log');
|
|
@@ -1727,7 +1807,7 @@ async function serve(root, args = {}) {
|
|
|
1727
1807
|
// the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
|
|
1728
1808
|
// watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
|
|
1729
1809
|
const unfollowHome = followHome(root, {
|
|
1730
|
-
onChange: (name) => log(`home changed (${name}): read again on its next use`),
|
|
1810
|
+
onChange: (name) => { log(`home changed (${name}): read again on its next use`); listenerRevision++; listenerBootstrapped = false; fleetListeners?.suspend(); reloadListeners(); },
|
|
1731
1811
|
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
1812
|
});
|
|
1733
1813
|
// Hermes's install is located once per process (a launch's memory snapshot reads it): here, before any verb is
|
|
@@ -1767,7 +1847,7 @@ async function serve(root, args = {}) {
|
|
|
1767
1847
|
} });
|
|
1768
1848
|
// 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
1849
|
// 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'));
|
|
1850
|
+
const unfollowPanes = onPanesChanged(() => events?.wake('machine-record'));
|
|
1771
1851
|
// the owner holds its boards only now, its watch installed (watch before load): opened once, changed by events
|
|
1772
1852
|
holdBoards(root);
|
|
1773
1853
|
// a pause of the collector holds the loop like any synchronous step: each is a step, so a block line names it
|
|
@@ -1789,6 +1869,58 @@ async function serve(root, args = {}) {
|
|
|
1789
1869
|
};
|
|
1790
1870
|
await registerOwner();
|
|
1791
1871
|
onLocalReopen(registerOwner);
|
|
1872
|
+
let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
|
|
1873
|
+
const listenerErrors = new Set();
|
|
1874
|
+
const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
|
|
1875
|
+
const scanListeners = ({ waiting = true } = {}) => listenerBootstrapped && fleetRecordsCurrent() && ownCall(() => observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting }));
|
|
1876
|
+
const readListenerRecords = () => {
|
|
1877
|
+
if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
|
|
1878
|
+
const revision = listenerRevision;
|
|
1879
|
+
listenerReading = readFleetRecords().then(records => {
|
|
1880
|
+
if (revision !== listenerRevision || !fleetListeners.enabled || stopped) return;
|
|
1881
|
+
listenerRecords = records;
|
|
1882
|
+
if (!listenerBootstrapped) { fleetListeners.bootstrap(records); listenerBootstrapped = true; scanListeners(); }
|
|
1883
|
+
})
|
|
1884
|
+
.catch(error => { listenerRecords = {}; listenerLog(`fleet listener records unread: ${String(error.message).slice(0, 300)}`); })
|
|
1885
|
+
.finally(() => { listenerReading = null; });
|
|
1886
|
+
return listenerReading;
|
|
1887
|
+
};
|
|
1888
|
+
fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
|
|
1889
|
+
file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
|
|
1890
|
+
enrich: event => ownCall(() => enrichFleetEvent(root, event)),
|
|
1891
|
+
retry: () => scanListeners({ waiting: false }),
|
|
1892
|
+
});
|
|
1893
|
+
const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
|
|
1894
|
+
reloadListeners = () => {
|
|
1895
|
+
listenerReloadDue = true;
|
|
1896
|
+
if (listenerReloading || stopped) return listenerReloading;
|
|
1897
|
+
listenerReloading = (async () => {
|
|
1898
|
+
while (listenerReloadDue && !stopped) {
|
|
1899
|
+
listenerReloadDue = false;
|
|
1900
|
+
const revision = listenerRevision;
|
|
1901
|
+
try {
|
|
1902
|
+
const orchestration = await ownCall(() => readHome(root));
|
|
1903
|
+
if (revision !== listenerRevision || stopped) continue;
|
|
1904
|
+
fleetListeners.configure(orchestration.workflow?.listeners ?? {});
|
|
1905
|
+
if (fleetListeners.enabled) { scanListeners(); void readListenerRecords(); }
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
fleetListeners.suspend();
|
|
1908
|
+
listenerLog(`fleet listener configuration unread; retained obligations wait: ${String(error.message).slice(0, 300)}`);
|
|
1909
|
+
// A failed source read is retried by the next round; a failed home read also retains no stale intent.
|
|
1910
|
+
listenerReloadDue = true; break;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
})().finally(() => { listenerReloading = null; });
|
|
1914
|
+
return listenerReloading;
|
|
1915
|
+
};
|
|
1916
|
+
await reloadListeners();
|
|
1917
|
+
// Retry a failed home decode without waiting for a dispatch sweep. Reading the held stream also enforces its
|
|
1918
|
+
// heartbeat deadline and reconnects after local caller loss, even when every board's dispatching is off.
|
|
1919
|
+
let nextListenerRead = 0;
|
|
1920
|
+
const listenerTimer = setInterval(() => {
|
|
1921
|
+
if (listenerReloadDue) void reloadListeners();
|
|
1922
|
+
if (Date.now() >= nextListenerRead) { nextListenerRead = Date.now() + 5000; void readListenerRecords(); }
|
|
1923
|
+
}, 2000); listenerTimer.unref?.();
|
|
1792
1924
|
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
1925
|
const stuck = {};
|
|
1794
1926
|
const warned = {};
|
|
@@ -1798,6 +1930,8 @@ async function serve(root, args = {}) {
|
|
|
1798
1930
|
let wakeReason = null;
|
|
1799
1931
|
try {
|
|
1800
1932
|
while (!stopped) {
|
|
1933
|
+
if (listenerReloadDue) void reloadListeners();
|
|
1934
|
+
if (fleetListeners.enabled) scanListeners();
|
|
1801
1935
|
// A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
|
|
1802
1936
|
// starts the new one. Nothing waits for an installation; a second round, old or new, is harmless.
|
|
1803
1937
|
const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
|
|
@@ -1866,6 +2000,10 @@ async function serve(root, args = {}) {
|
|
|
1866
2000
|
if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1867
2001
|
}
|
|
1868
2002
|
} finally {
|
|
2003
|
+
fleetListeners.suspend(); clearInterval(listenerTimer); unfollowRecords(); unfollowPanes();
|
|
2004
|
+
await listenerReloading;
|
|
2005
|
+
await listenerReading;
|
|
2006
|
+
try { await fleetListeners.close(); } catch (error) { log(`fleet listener shutdown: retained filing outcome unknown (${String(error.message).slice(0, 300)})`); }
|
|
1869
2007
|
// a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
|
|
1870
2008
|
try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
|
|
1871
2009
|
unwatchLoop?.(); releaseHeldBoards(); closeVerbs();
|