@volter/supercode-orchestrator 0.5.226 → 0.5.227
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -18
- package/adapters/rh2.mjs +6 -5
- package/bin/connection-runner.mjs +46 -12
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +6 -1
- package/board/agent-declarations.mjs +1 -1
- package/board/args.mjs +8 -1
- package/board/call.mjs +2 -2
- package/board/ci.mjs +158 -54
- package/board/cli.mjs +175 -53
- package/board/custody.mjs +5 -1
- package/board/dispatch.mjs +969 -256
- package/board/doors.mjs +7 -2
- package/board/engine.mjs +264 -48
- package/board/event-stream.mjs +3 -7
- package/board/files.mjs +4 -1
- package/board/fleet-listeners.mjs +3 -1
- package/board/fleet-observer.mjs +3 -2
- package/board/generation-owner.mjs +40 -10
- package/board/generation.mjs +17 -4
- package/board/machine-fleet.mjs +33 -9
- package/board/notices.mjs +10 -2
- package/board/published.mjs +107 -2
- package/board/readable-id.mjs +25 -2
- package/board/runtime-door.mjs +2 -2
- package/board/session-work.mjs +15 -1
- package/board/storage-contract.mjs +41 -3
- package/board/store.mjs +49 -7
- package/board/tools.mjs +1 -1
- package/board/workflow.mjs +197 -70
- package/board/workspace.mjs +99 -13
- package/board/ztrack.mjs +42 -14
- package/connection.mjs +252 -32
- package/index.d.ts +5 -1
- package/installed-apps.mjs +11 -5
- package/loop.mjs +6 -0
- package/mcp/hermes-kanban-tools.json +10 -1
- package/package.json +6 -6
- package/reducer.mjs +31 -2
package/board/cli.mjs
CHANGED
|
@@ -12,15 +12,15 @@ import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from
|
|
|
12
12
|
import { current, isCallersCall, ownCall, within } from './call.mjs';
|
|
13
13
|
import { cardState, holdDescription } from './card-state.mjs';
|
|
14
14
|
import { detachSession } from './custody.mjs';
|
|
15
|
-
import { taskStatusOf } from './published.mjs';
|
|
15
|
+
import { reclaimSpace, taskStatusOf, trimDue, trimPublications } from './published.mjs';
|
|
16
16
|
import { executionOf, readingContext } from './session-work.mjs';
|
|
17
17
|
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
18
18
|
import { receiveAttachment } from './attachment-write.mjs';
|
|
19
19
|
import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
|
|
20
20
|
import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
|
|
21
21
|
import { openOwnerDoor } from './owner-door.mjs';
|
|
22
|
-
import { inBoardGeneration, generationWork, admitGeneration, grantGenerationWriter, parkGeneration, stopGeneration, tellOwner } from './generation.mjs';
|
|
23
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { inBoardGeneration, generationWork, admitGeneration, grantGenerationWriter, parkGeneration, stopGeneration, tellOwner, admittedWork, GENERATION_STOPPED } from './generation.mjs';
|
|
23
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
24
24
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
25
25
|
import { execFile, spawnSync } from 'node:child_process';
|
|
26
26
|
import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
|
|
@@ -76,13 +76,13 @@ async function boardPublishing(root) {
|
|
|
76
76
|
const board = listed?.boards?.find((b) => b.root === here);
|
|
77
77
|
return board ? { ...board, refused: listed.refused ?? null } : null;
|
|
78
78
|
}
|
|
79
|
-
import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
79
|
+
import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, closeEnded, lanesOf, localMachine, observeFleet, paused, plan, startCard, releaseClaims, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
80
80
|
import { watchBoard } from './wake.mjs';
|
|
81
81
|
import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
|
|
82
82
|
import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
|
|
83
83
|
import { FleetListenerRouter, fleetListenerStatus } from './fleet-listeners.mjs';
|
|
84
84
|
import { enrichFleetEvent, observeRoleFleet } from './fleet-observer.mjs';
|
|
85
|
-
import { fileFleetNotice } from './dispatch.mjs';
|
|
85
|
+
import { drainAgentCredentials, fileFleetNotice } from './dispatch.mjs';
|
|
86
86
|
import { streamBoardPublications } from './event-stream.mjs';
|
|
87
87
|
import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor, watchWorkflows } from './workflow.mjs';
|
|
88
88
|
import { evaluate } from './expr.mjs';
|
|
@@ -101,18 +101,20 @@ const callerPath = (named) => notRuntime(resolve(current().cwd, named));
|
|
|
101
101
|
const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
102
102
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
103
103
|
|
|
104
|
-
A card is named by its id (t_4406c88a) or by its number, keyed or bare (VO-42, vo-42, #42
|
|
105
|
-
gave it when it was made, read with the board's key, the first letters of the board's name.
|
|
104
|
+
A card is named by its readable id (voice-calls), by its id (t_4406c88a) or by its number, keyed or bare (VO-42, vo-42, #42
|
|
105
|
+
or 42): the short number the board gave it when it was made, read with the board's key, the first letters of the board's name.
|
|
106
|
+
Its creator gives the readable id, a few lowercase words naming its outcome, unique on the board; it names the card's branch,
|
|
107
|
+
worktree folder, sessions and agents.
|
|
106
108
|
|
|
107
109
|
The board's verbs, with their flags:
|
|
108
110
|
declaration [--json] read the serving board declaration
|
|
109
111
|
init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
|
|
110
112
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
111
113
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
112
|
-
create <title>
|
|
114
|
+
create <title> --readable-id ID [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
113
115
|
[--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
|
|
114
116
|
[--model] [--provider] [--reviewer PROFILE] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
115
|
-
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
117
|
+
swarm <goal> --readable-id ID --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
116
118
|
list [--mine] [--assignee] [--status] [--tenant] [--session] [--subtasks] [--archived] [--sort] show <id> context <id> brief <id> [--run N] runs <id>
|
|
117
119
|
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]
|
|
118
120
|
reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
|
|
@@ -120,11 +122,14 @@ The board's verbs, with their flags:
|
|
|
120
122
|
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch... | --source none] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
121
123
|
attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
|
|
122
124
|
complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
|
|
125
|
+
person-done <id> --person person:<principal> (--result <answer> | --result-file PATH|-) [--workplace-task <Task id>]
|
|
126
|
+
a person's card (create/set --assignee person:<principal>) closes on that person's completion: the Workplace
|
|
127
|
+
integration reports it from the home; the board checks it names the card's person. Never a session's verb.
|
|
123
128
|
finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
|
|
124
129
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
125
130
|
pause <id> --reason <why> resume <id> cancel <id> --reason <why>
|
|
126
131
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
127
|
-
unblock <id>... --on <blocker> --reason <what arrived> request-review <id>
|
|
132
|
+
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> [--plan] --pr URL --pr-head SHA [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... --reason <why> (voids the verdict, or restarts a review run that has none) return-review <id> --reason <why> (manager: back to its coder, no verdict) reopen <id>... [--reason]
|
|
128
133
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
129
134
|
specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
130
135
|
decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
|
|
@@ -132,7 +137,7 @@ The board's verbs, with their flags:
|
|
|
132
137
|
github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
|
|
133
138
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
134
139
|
capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
|
|
135
|
-
dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
|
|
140
|
+
dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] compact repair
|
|
136
141
|
pause [reason] · resume (the board's emergency stop: new starts stop, running sessions go on)
|
|
137
142
|
|
|
138
143
|
And:
|
|
@@ -154,7 +159,10 @@ And:
|
|
|
154
159
|
handover --entry <orchestrator.mjs> --supercode <native> prepare a board generation, retain its predecessor
|
|
155
160
|
answer <id> <option> choose an option (its number or label) of the question the card's session shows on its pane: the
|
|
156
161
|
card's manager, its creator or a signed-in person; recorded on the card as question_answered with who answered
|
|
157
|
-
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
162
|
+
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency|participant]
|
|
163
|
+
notify-unsubscribe <id> [--address A]: stop following a card; the board says so on the card, and an @mention of you
|
|
164
|
+
(or your own comment) brings you back. A comment's author and everyone it @mentions (@manager, @sc:<address>,
|
|
165
|
+
@<agent>) follow the card; every comment reaches every follower but its author.
|
|
158
166
|
notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
|
|
159
167
|
notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
|
|
160
168
|
Any verb … --at sc:<machine>:board:<home>: applied on that board through this machine's board door, as this session; its answer and exit code are the board's.
|
|
@@ -167,7 +175,8 @@ And:
|
|
|
167
175
|
specify <id> --workspace … --machine M: declares the workspace on M and moves the card there, never asking the machine it leaves.
|
|
168
176
|
comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; each declaration adds to the earlier ones, on any run, and --pr none alone clears them. This records evidence; only the board starts its review.
|
|
169
177
|
comment <arc> TEXT --source owner/repo:branch...: the arc's implementer or the manager declares every repository and branch the arc changes, on the arc itself; its review discovers PRs from them and accepts --pr declarations in them, with no task registered there. Each declaration adds to the earlier ones, and --source none alone clears them.
|
|
170
|
-
request-review <id>
|
|
178
|
+
request-review <id> --pr URL --pr-head SHA [--summary "<what the PR does>"]: hand one ready PR to its PR review at its pushed head, whatever other PRs the arc has; merge it at that head once the review passes. A request naming its PR is a PR review.
|
|
179
|
+
request-review <id> --plan --pr URL --pr-head SHA: a big arc's plan review, of the proposed ADR's PR at its pushed head, before any code. The arc's final review (of every PR not merged at a PR-reviewed head, with its Acceptance) is the board's.
|
|
171
180
|
request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
|
|
172
181
|
request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
|
|
173
182
|
complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
|
|
@@ -342,7 +351,7 @@ const ts = (t) => {
|
|
|
342
351
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
343
352
|
};
|
|
344
353
|
// a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
|
|
345
|
-
const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.readable_id).padEnd(
|
|
354
|
+
const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.number ? t.readable_id : null).padEnd(32)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}${t.execution?.reason ? ` — ${t.execution.reason}` : ''}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
346
355
|
// `key` is the card's board's (store.mjs boardKey); `ref` the card as a person names it (`VO-856`)
|
|
347
356
|
const stateOptions = root => ({ nativeHolds: Boolean(workflowFor(root).workflow.flags?.paused) });
|
|
348
357
|
const cardDict = (raw, key, options) => { const t = cardState(raw, options); return ({
|
|
@@ -388,13 +397,14 @@ const metadataOf = (v) => {
|
|
|
388
397
|
// included). A number no card here has, or a key that is not this board's, is refused. A key is two or more letters, so
|
|
389
398
|
// a message id (`m-…`, `a-…`) is never read as one.
|
|
390
399
|
const NO_CARD_WORDS = new Set(['create', 'new', 'swarm', 'list', 'ls', 'migrate', 'init', 'boards', 'usage', 'events', 'listeners', 'dispatch',
|
|
391
|
-
'capacity', 'deliver', 'daemon', 'serve', 'gc', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
|
|
400
|
+
'capacity', 'deliver', 'daemon', 'serve', 'gc', 'compact', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
|
|
392
401
|
'watch', 'help', 'github-usage']);
|
|
393
402
|
// the verbs that act on every positional as a card (each loops over them all)
|
|
394
403
|
const ALL_CARD_WORDS = new Set(['complete', 'archive', 'request-state', 'request_state', 'set-goal', 'unblock', 'reopen', 'reopen-review']);
|
|
395
404
|
const CARD_FLAGS = ['ids', 'subtask-of', 'parent', 'on', 'for-card', 'task', 'rm'];
|
|
396
405
|
const CARD_NUMBER = /^(?:#|([A-Za-z][A-Za-z0-9]+)-)?([1-9]\d*)$/;
|
|
397
|
-
|
|
406
|
+
const READABLE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
407
|
+
function resolveCardNumbers(verb, rest, args, withBoard, keyOf, exists) {
|
|
398
408
|
const words = NO_CARD_WORDS.has(verb) ? 0 : ALL_CARD_WORDS.has(verb) ? rest.length : ['link', 'unlink'].includes(verb) ? 2 : 1;
|
|
399
409
|
const places = [];
|
|
400
410
|
for (let i = 0; i < Math.min(words, rest.length); i++) places.push([rest, i]);
|
|
@@ -403,9 +413,12 @@ function resolveCardNumbers(verb, rest, args, withBoard, keyOf) {
|
|
|
403
413
|
else if (typeof args[flag] === 'string') places.push([args, flag]);
|
|
404
414
|
}
|
|
405
415
|
const numbered = places.filter(([holder, key]) => CARD_NUMBER.test(String(holder[key])));
|
|
406
|
-
|
|
416
|
+
// a card named by its readable id (`voice-calls`) is that card, where one is; any other word stays as it was said
|
|
417
|
+
const named = exists() ? places.filter(([holder, key]) => !CARD_NUMBER.test(String(holder[key])) && READABLE_ID.test(String(holder[key]))) : [];
|
|
418
|
+
if (!numbered.length && !named.length) return;
|
|
407
419
|
const own = keyOf();
|
|
408
420
|
withBoard((b) => view(b, (db) => {
|
|
421
|
+
for (const [holder, at] of named) holder[at] = engine.cardByReadableId(db, String(holder[at])) ?? holder[at];
|
|
409
422
|
for (const [holder, at] of numbered) {
|
|
410
423
|
const [, named, digits] = CARD_NUMBER.exec(String(holder[at]));
|
|
411
424
|
if (named && named.toUpperCase() !== own) throw new Error(`${holder[at]} names a card of board ${named.toUpperCase()}, not of this board (${own}); nothing was done`);
|
|
@@ -448,7 +461,7 @@ export async function runVerb(argv) {
|
|
|
448
461
|
const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
|
|
449
462
|
// the board's key (store.mjs boardKey), read when a card is named or shown by its number
|
|
450
463
|
const keyOf = () => boardKey(root, slug);
|
|
451
|
-
resolveCardNumbers(verb, rest, args, withBoard, keyOf);
|
|
464
|
+
resolveCardNumbers(verb, rest, args, withBoard, keyOf, () => boardExists(root, slug));
|
|
452
465
|
const id = rest[0];
|
|
453
466
|
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
454
467
|
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
@@ -600,7 +613,7 @@ export async function runVerb(argv) {
|
|
|
600
613
|
// thread records the start. A create never runs a dispatch tick inline.
|
|
601
614
|
// the creator hears what happens from here on; a start answered above is not told twice
|
|
602
615
|
const address = args['no-notify'] ? null : args.notify ?? callerAddress();
|
|
603
|
-
if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
|
|
616
|
+
if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator', { own: true }));
|
|
604
617
|
const task = withBoard((b) => view(b, (db) => engine.getTask(db, created.id)));
|
|
605
618
|
const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
|
|
606
619
|
if (args.json) { say(args, '', { ...cardDict(task, keyOf(), stateOptions(root)), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
|
|
@@ -617,7 +630,7 @@ export async function runVerb(argv) {
|
|
|
617
630
|
return { profile, title, skills: skills ? skills.split(',').map((x) => x.trim()).filter(Boolean) : [] };
|
|
618
631
|
});
|
|
619
632
|
if (!args.verifier || !args.synthesizer) throw new Error('a swarm names its --verifier and --synthesizer');
|
|
620
|
-
const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ?? null, priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
|
|
633
|
+
const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), readableId: args['readable-id'], workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ?? null, priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
|
|
621
634
|
say(args, `Swarm root: ${made.root_id}\nWorkers: ${(made.worker_ids ?? []).join(', ')}\nVerifier: ${made.verifier_id}\nSynthesizer: ${made.synthesizer_id}`, made);
|
|
622
635
|
return 0;
|
|
623
636
|
}
|
|
@@ -672,10 +685,10 @@ export async function runVerb(argv) {
|
|
|
672
685
|
say(args, '', { task: cardDict(t, keyOf(), stateOptions(root)), sources: withBoard((b) => arcSources(b.db, t.id)), 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 });
|
|
673
686
|
return 0;
|
|
674
687
|
}
|
|
675
|
-
const labels = withBoard((b) => view(b, (db) => engine.numbersOf(db, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
|
|
676
688
|
const boardKeyNow = keyOf();
|
|
677
|
-
const
|
|
678
|
-
const
|
|
689
|
+
const labels = withBoard((b) => view(b, (db) => engine.labelsOf(db, boardKeyNow, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
|
|
690
|
+
const label = (cardId) => labels.get(cardId) ?? cardId;
|
|
691
|
+
const out = [`Task ${engine.cardLabel(boardKeyNow, t.number, t.id, t.subtask_of ? null : t.readable_id)}: ${t.title}`, ` status: ${['paused','blocked'].includes(t.status) ? t.status : taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage})`, ` assignee: ${t.assignee || '-'}`];
|
|
679
692
|
if (holdDescription(t)) out.push(` hold: ${holdDescription(t)}`);
|
|
680
693
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
681
694
|
if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
|
|
@@ -853,6 +866,8 @@ export async function runVerb(argv) {
|
|
|
853
866
|
|
|
854
867
|
case 'comment': {
|
|
855
868
|
need(args.file !== undefined ? 1 : 2, 'a card id and the text (or --file PATH, - for stdin)');
|
|
869
|
+
// A comment's text comes from --file or from its words, never both: words beside --file were dropped unread.
|
|
870
|
+
if (args.file !== undefined && rest.length > 1) throw new Error(`comment ${id} takes its text from --file or from words, not both; unexpected: ${JSON.stringify(rest.slice(1).join(' ').slice(0, 120))}. Nothing was recorded.`);
|
|
856
871
|
let text = textFrom(args.file, rest.slice(1).join(' '));
|
|
857
872
|
if (args['max-len']) text = text.slice(0, Number(args['max-len']));
|
|
858
873
|
const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text, { pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, source: args.source ?? null }));
|
|
@@ -908,6 +923,25 @@ export async function runVerb(argv) {
|
|
|
908
923
|
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 }));
|
|
909
924
|
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
910
925
|
}
|
|
926
|
+
// A person completed the card assigned to them, reported by the Workplace integration through the home it serves
|
|
927
|
+
// (sdk/teams/workplace/organization.mjs), as the board's own platform event; a session never reports one.
|
|
928
|
+
case 'person-done': {
|
|
929
|
+
need(1, 'a card id');
|
|
930
|
+
if (sid) throw new Error('A person\'s completion is reported by the Workplace integration from the home, never by a session; nothing was done.');
|
|
931
|
+
// The board's refusal (the card is no person's, or another person's, or already ended) is said with exit code 2: the
|
|
932
|
+
// reporter does not send it again. Any other failure is the reporter's to retry; the same report again is `already`.
|
|
933
|
+
let done;
|
|
934
|
+
try { done = await withBoardAsync((b) => engine.personDone(b, id, { person: args.person ?? null, result: textFrom(args['result-file'], args.result), task: args['workplace-task'] ?? null })); }
|
|
935
|
+
catch (error) {
|
|
936
|
+
// the card's refusal is final, as dispatch.mjs takes a moved card's (a transition that does not apply there), and
|
|
937
|
+
// so is an answer refused as a credential dump: sent again, it is refused again
|
|
938
|
+
if (!error.refusal && !error.noTransition && !/does not apply|there is for/.test(String(error.message)) && error.code !== 'credential_dump') throw error;
|
|
939
|
+
say(args, `${id} refused ${args.person ?? 'the person'}'s completion: ${error.message}`, { ok: false, card: id, person: args.person ?? null, refused: error.message });
|
|
940
|
+
return 2;
|
|
941
|
+
}
|
|
942
|
+
say(args, done.already ? `${id} was already completed by ${args.person}` : `Completed ${id} for ${args.person}`, { ok: true, card: id, person: args.person, already: done.already });
|
|
943
|
+
return 0;
|
|
944
|
+
}
|
|
911
945
|
case 'edit': {
|
|
912
946
|
need(1, 'a card id');
|
|
913
947
|
if (args.result === undefined) throw new Error('edit needs --result');
|
|
@@ -935,7 +969,7 @@ export async function runVerb(argv) {
|
|
|
935
969
|
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
936
970
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
937
971
|
}
|
|
938
|
-
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;
|
|
972
|
+
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, plan: Boolean(args.plan), force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
939
973
|
case 'request-changes': {
|
|
940
974
|
need(2, 'a card id and the reason');
|
|
941
975
|
// each finding is filed as a task on the arc first: the arc's review is due again once every one is closed (ci.mjs answeredChanges)
|
|
@@ -955,8 +989,17 @@ export async function runVerb(argv) {
|
|
|
955
989
|
case 'reopen-review': {
|
|
956
990
|
need(1, 'a card id');
|
|
957
991
|
const results = [];
|
|
958
|
-
for (const t of ids())
|
|
959
|
-
|
|
992
|
+
for (const t of ids()) { const moved = await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null })); results.push([t, moved.to, moved.restarted_run ?? null]); }
|
|
993
|
+
// a review still running with no verdict is restarted, not voided: its run ends, its reviewer session is closed and
|
|
994
|
+
// the board's next review run resumes that same session with the round's brief
|
|
995
|
+
say(args, results.map(([t, s, run]) => run ? `Restarted ${t}'s review: run ${run} ended with no verdict and its reviewer session was closed; the board's next review run resumes it (${s}). No verdict was voided.` : `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
996
|
+
}
|
|
997
|
+
case 'return-review': {
|
|
998
|
+
// the manager's door out of review: a card there by mistake goes back to its coder, its review run ending as returned
|
|
999
|
+
need(1, 'a card id');
|
|
1000
|
+
const reason = args.reason ?? (rest.slice(1).join(' ') || null);
|
|
1001
|
+
const moved = await withBoardAsync((b) => engine.returnReview(b, id, { reason }));
|
|
1002
|
+
say(args, `Returned ${id} from review → ${moved.to}: its review run ended as returned with no verdict; its coder continues, and the board reviews again once a pushed PR head changes.`, { ok: true, ...moved }); return 0;
|
|
960
1003
|
}
|
|
961
1004
|
case 'promote': {
|
|
962
1005
|
need(1, 'a card id');
|
|
@@ -1000,7 +1043,7 @@ export async function runVerb(argv) {
|
|
|
1000
1043
|
for (const t of targets) {
|
|
1001
1044
|
if (verb === 'specify' && (args['readable-id'] !== undefined || args.title !== undefined || args.body !== undefined || args['body-file'] !== undefined || args.workspace !== undefined || args.branch !== undefined || args.machine !== undefined)) {
|
|
1002
1045
|
const moved = await withBoardAsync((b) => engine.specify(b, t, { readableId: args['readable-id'] ?? null, title: args.title ?? null, body: textFrom(args['body-file'], args.body) ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, machine: args.machine ?? null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
1003
|
-
results.push({ id: t, specified: true, status: moved.to });
|
|
1046
|
+
results.push({ id: t, specified: true, status: moved.to, from: moved.from, moved: Boolean(moved.moved) });
|
|
1004
1047
|
} else if (verb === 'decompose' && args.child?.length) {
|
|
1005
1048
|
const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
|
|
1006
1049
|
results.push({ id: t, children: await withBoardAsync((b) => engine.decompose(b, t, children, { author: args.author ?? callerAddress() ?? callerName() })) });
|
|
@@ -1009,7 +1052,7 @@ export async function runVerb(argv) {
|
|
|
1009
1052
|
results.push({ id: t, session: session.address, mode: session.mode });
|
|
1010
1053
|
}
|
|
1011
1054
|
}
|
|
1012
|
-
say(args, results.map((r) => (r.specified ? `Specified ${r.id} → ${r.status}` : r.children ? `Decomposed ${r.id} into ${r.children.join(', ')}` : `${r.id}: session ${r.session} (${r.mode}) is writing its ${verb === 'specify' ? 'spec' : 'breakdown'}`)).join('\n'), results);
|
|
1055
|
+
say(args, results.map((r) => (r.specified ? (r.moved ? `Specified ${r.id}: ${r.from} → ${r.status}` : `Specified ${r.id}; it stays ${r.status}`) : r.children ? `Decomposed ${r.id} into ${r.children.join(', ')}` : `${r.id}: session ${r.session} (${r.mode}) is writing its ${verb === 'specify' ? 'spec' : 'breakdown'}`)).join('\n'), results);
|
|
1013
1056
|
return 0;
|
|
1014
1057
|
}
|
|
1015
1058
|
|
|
@@ -1050,7 +1093,7 @@ export async function runVerb(argv) {
|
|
|
1050
1093
|
if (!existsSync(file)) {
|
|
1051
1094
|
// a pane session keeps no log file: its transcript is the harness's own, named by the run
|
|
1052
1095
|
const s = withBoard((b) => view(b, (db) => engine.lastSession(db, id)));
|
|
1053
|
-
current().out(s ? `(${id} runs as session ${s.address}; its transcript is that session's: \`supercode sessions show ${s.session_id}\`)\n` : `(no log for ${id})\n`);
|
|
1096
|
+
current().out(s ? `(${id} runs as session ${s.address}; its transcript is that session's, read on whichever machine holds it: \`supercode sessions show ${s.address ?? s.session_id}\`)\n` : `(no log for ${id})\n`);
|
|
1054
1097
|
return 0;
|
|
1055
1098
|
}
|
|
1056
1099
|
const text = readFileSync(file, 'utf8');
|
|
@@ -1092,13 +1135,18 @@ export async function runVerb(argv) {
|
|
|
1092
1135
|
const platform = args.platform ?? 'supercode';
|
|
1093
1136
|
const chat = args['chat-id'] ?? args.address ?? (platform === 'supercode' ? callerAddress() : null);
|
|
1094
1137
|
if (!chat) throw new Error(`${verb} names the subscriber: --platform P --chat-id C (a supercode session's address with --platform supercode)`);
|
|
1138
|
+
// a supercode follower's unsubscribe is the card's (engine.unsubscribe: kept, said on the card); a chat's ends here
|
|
1139
|
+
if (verb === 'notify-unsubscribe' && platform === 'supercode' && !args['thread-id']) {
|
|
1140
|
+
const gone = withBoard((b) => engine.unsubscribe(b, id, chat));
|
|
1141
|
+
say(args, gone ? `Unsubscribed ${chat} from ${id}; mention @${engine.mentionName(chat)} on the card to bring them back` : `${chat} was not subscribed to ${id}`, { ok: gone }); return gone ? 0 : 1;
|
|
1142
|
+
}
|
|
1095
1143
|
if (verb === 'notify-unsubscribe') {
|
|
1096
1144
|
const gone = withBoard((b) => tx(b, (db) => db.prepare('DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?').run(id, platform, chat, args['thread-id'] ?? '').changes > 0));
|
|
1097
1145
|
say(args, gone ? `Unsubscribed ${platform}:${chat} from ${id}` : `${platform}:${chat} was not subscribed to ${id}`, { ok: gone }); return gone ? 0 : 1;
|
|
1098
1146
|
}
|
|
1099
1147
|
withBoard((b) => tx(b, (db) => {
|
|
1100
1148
|
if (!engine.getTask(db, id)) throw new Error(`no card ${id}`);
|
|
1101
|
-
if (platform === 'supercode') return engine.subscribeIn(db, id, chat, args.role ?? 'creator');
|
|
1149
|
+
if (platform === 'supercode') return engine.subscribeIn(db, id, chat, args.role ?? 'creator', { explicit: true });
|
|
1102
1150
|
const cursor = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(id).c;
|
|
1103
1151
|
db.prepare(`INSERT INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, created_at, last_event_id)
|
|
1104
1152
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id, platform, chat_id, thread_id) DO UPDATE SET chat_type = COALESCE(excluded.chat_type, chat_type), delivery_mode = excluded.delivery_mode`)
|
|
@@ -1110,7 +1158,7 @@ export async function runVerb(argv) {
|
|
|
1110
1158
|
case 'notify-list': {
|
|
1111
1159
|
const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
|
|
1112
1160
|
if (args.json) { say(args, '', subs); return 0; }
|
|
1113
|
-
current().out(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
|
|
1161
|
+
current().out(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? `${s.delivery_metadata?.role ?? 'creator'}${s.delivery_metadata?.unsubscribed ? ' (unsubscribed)' : ''}` : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
|
|
1114
1162
|
return 0;
|
|
1115
1163
|
}
|
|
1116
1164
|
|
|
@@ -1121,7 +1169,7 @@ export async function runVerb(argv) {
|
|
|
1121
1169
|
// planned through the workflow's own dispatch (its start event, eligible, order and limits), writing nothing
|
|
1122
1170
|
const reports = await plan({ root, lanes, settings, params, slugs: [slug] });
|
|
1123
1171
|
const would = reports.flatMap((r) => r.would), reviews = reports.flatMap((r) => r.would_review ?? []);
|
|
1124
|
-
const lines = [...reviews.map((r) => `would request review of ${r.id}: ${r.no_code ? r.no_code.kind === 'disposition' ? 'scratch operational disposition (card evidence and closed tasks)' : `no code changed (${r.no_code.repository} ${r.no_code.branch} holds no commit beyond ${r.no_code.base}, no PR from it)` : r.prs.map((url, i) => `${url} at ${r.heads[i]}`).join(', ')}`),
|
|
1172
|
+
const lines = [...reviews.map((r) => `would request review of ${r.id}: ${r.no_code ? r.no_code.kind === 'disposition' ? 'scratch operational disposition (card evidence and closed tasks)' : `no code changed (${r.no_code.repository} ${r.no_code.branch} holds no commit beyond ${r.no_code.base}, no PR from it)` : [...r.prs.map((url, i) => `${url} at ${r.heads[i]}`), ...(r.pr_reviewed ?? []).map((p) => `${p.url} at ${p.head} (settled by its PR review)`)].join(', ')}`),
|
|
1125
1173
|
...would.map((w) => `would start ${w.id} (${w.status}${w.role ? `, its ${w.role}` : ''}) as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`)];
|
|
1126
1174
|
say(args, lines.length ? lines.join('\n') : 'nothing would start', args.json ? { would, would_review: reviews, queued: reports.flatMap(r => r.queued ?? []) } : would);
|
|
1127
1175
|
return 0;
|
|
@@ -1142,12 +1190,12 @@ export async function runVerb(argv) {
|
|
|
1142
1190
|
const reports = await plan({ root, lanes, settings });
|
|
1143
1191
|
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 }))) };
|
|
1144
1192
|
if (args.json) { say(args, '', out); return 0; }
|
|
1145
|
-
// each card as a person reads it, by the number its own board gave it
|
|
1193
|
+
// each card as a person reads it, by its readable id and the number its own board gave it
|
|
1146
1194
|
const shown = [...out.would, ...out.waiting.map((w) => w.id), ...out.capacity.flatMap((cap) => cap.groups.flatMap((g) => g.cards))];
|
|
1147
1195
|
const labels = new Map();
|
|
1148
1196
|
for (const board of listBoards(root)) {
|
|
1149
1197
|
const b = openBoard(root, board);
|
|
1150
|
-
try { const own = boardKey(root, board); for (const [cardId,
|
|
1198
|
+
try { const own = boardKey(root, board); for (const [cardId, label] of view(b, (db) => engine.labelsOf(db, own, shown))) labels.set(cardId, label); } finally { b.close(); }
|
|
1151
1199
|
}
|
|
1152
1200
|
const label = (cardId) => labels.get(cardId) ?? cardId;
|
|
1153
1201
|
const lines = [];
|
|
@@ -1200,6 +1248,44 @@ export async function runVerb(argv) {
|
|
|
1200
1248
|
say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
|
|
1201
1249
|
return 0;
|
|
1202
1250
|
}
|
|
1251
|
+
case 'compact': {
|
|
1252
|
+
// The store gives its free pages back to the volume (published.mjs reclaimSpace), answered by the board's owner on
|
|
1253
|
+
// its own connection: the first time a rebuild, which holds the board's writer (and the owner) while it copies
|
|
1254
|
+
// every live page, later a slice; then its WAL is checkpointed and cut. It deletes nothing a reader reads.
|
|
1255
|
+
const sized = (file) => { try { return statSync(file).size; } catch { return 0; } };
|
|
1256
|
+
const report = await withBoardAsync(async (b) => {
|
|
1257
|
+
// the store's own file, as its connection names it (a ztrack board's path is its marker; its store is beside it)
|
|
1258
|
+
const path = b.db.prepare('PRAGMA database_list').all().find((d) => d.name === 'main')?.file;
|
|
1259
|
+
if (!path) throw new Error(`board ${slug} has no store file to compact`);
|
|
1260
|
+
const onDisk = () => sized(path) + sized(`${path}-wal`);
|
|
1261
|
+
const vacuum = (sql) => (b.vacuum ? b.vacuum(sql) : b.db.exec(sql));
|
|
1262
|
+
const pageSize = Number(b.db.prepare('PRAGMA page_size').get().page_size);
|
|
1263
|
+
const pages = () => Number(b.db.prepare('PRAGMA page_count').get().page_count), free = () => Number(b.db.prepare('PRAGMA freelist_count').get().freelist_count);
|
|
1264
|
+
const before = { pages: pages(), free: free(), bytes: onDisk() }, began = performance.now();
|
|
1265
|
+
// The older changes still stored complete are cut first, one transaction a slice as the owner's wakes do, the
|
|
1266
|
+
// owner answering other calls between slices, so the rebuild gives back what they held. A slice that moves the
|
|
1267
|
+
// trim nowhere ends it.
|
|
1268
|
+
let trimmed = 0;
|
|
1269
|
+
const through = () => { try { return JSON.parse(b.db.prepare("SELECT value FROM supercode_board_state WHERE key = 'publication_trim'").get()?.value ?? 'null')?.through ?? null; } catch { return null; } };
|
|
1270
|
+
while (trimDue(b.db)) {
|
|
1271
|
+
const at = through();
|
|
1272
|
+
trimmed += b.tx((db) => trimPublications(db));
|
|
1273
|
+
if (through() === at) break;
|
|
1274
|
+
await new Promise((done) => setImmediate(done));
|
|
1275
|
+
}
|
|
1276
|
+
// The rebuild writes a copy of every live page and a WAL as large: it runs only with twice that and 1 GB more free
|
|
1277
|
+
// on the store's volume, and says so otherwise.
|
|
1278
|
+
const live = (pages() - free()) * pageSize, room = statfsSync(dirname(path)), available = Number(room.bavail) * Number(room.bsize);
|
|
1279
|
+
if (available < 2 * live + 1024 ** 3) return { trimmed, refused: `the rebuild needs ${Math.ceil((2 * live + 1024 ** 3) / 1048576)} MB free on the store's volume and ${Math.floor(available / 1048576)} MB are`, pages_before: before.pages, free_before: before.free };
|
|
1280
|
+
reclaimSpace(b.db, vacuum);
|
|
1281
|
+
// the WAL is cut once no reader holds it; a reader that does is waited for, a few times, then left to a later checkpoint
|
|
1282
|
+
for (let tries = 0; tries < 5 && sized(`${path}-wal`) > 0; tries++) { vacuum('PRAGMA wal_checkpoint(TRUNCATE)'); if (sized(`${path}-wal`) > 0) await new Promise((done) => setTimeout(done, 1000)); }
|
|
1283
|
+
return { trimmed, reclaimed_bytes: before.bytes - onDisk(), wal_bytes: sized(`${path}-wal`), pages_before: before.pages, free_before: before.free, pages_after: pages(), free_after: free(), seconds: Math.round((performance.now() - began) / 100) / 10 };
|
|
1284
|
+
});
|
|
1285
|
+
if (report.refused) { say(args, `${report.trimmed} older change(s) cut; no rebuild: ${report.refused}`, report); return 1; }
|
|
1286
|
+
say(args, `${report.trimmed} older change(s) cut; the store and its WAL are ${Math.round(report.reclaimed_bytes / 1048576)} MB smaller on disk after ${report.seconds} s (${report.pages_before} pages, ${report.free_before} free, to ${report.pages_after}, ${report.free_after} free; WAL ${report.wal_bytes} bytes)`, report);
|
|
1287
|
+
return 0;
|
|
1288
|
+
}
|
|
1203
1289
|
case 'repair': {
|
|
1204
1290
|
const path = boardPath(root, slug);
|
|
1205
1291
|
if (!existsSync(path)) { say(args, `No kanban DB at ${path} — nothing to repair.`, { status: 'missing', db_path: path }); return 0; }
|
|
@@ -1380,14 +1466,19 @@ async function boardsVerb(root, rest, args) {
|
|
|
1380
1466
|
const slug = normalSlug(target);
|
|
1381
1467
|
if (slug === DEFAULT_BOARD) throw new Error("the 'default' board cannot be removed");
|
|
1382
1468
|
if (!existsSync(boardDir(root, slug))) throw new Error(`board '${slug}' does not exist`);
|
|
1469
|
+
// Every machine this board handed an agent's own Workplace records keeps none of them from here; one that cannot
|
|
1470
|
+
// be told now is named, and its lease there ends the copy while that machine's daemon runs (dispatch.mjs
|
|
1471
|
+
// drainAgentCredentials).
|
|
1472
|
+
const left = boardExists(root, slug) ? await drainAgentCredentials(root, slug) : [];
|
|
1473
|
+
const leftLine = left.length ? `\n Agent credentials not yet removed (each ends at its lease while that machine's daemon runs): ${left.map((each) => `${each.agent} on ${each.machine} by ${each.leaseUntil ?? 'a day after its last keep'}`).join('; ')}` : '';
|
|
1383
1474
|
if (boardOf(root, {}) === slug) rmSync(currentFile(root), { force: true });
|
|
1384
|
-
if (args.delete || action === 'delete') { rmSync(boardDir(root, slug), { recursive: true, force: true }); say(args, `Board '${slug}' deleted
|
|
1475
|
+
if (args.delete || action === 'delete') { rmSync(boardDir(root, slug), { recursive: true, force: true }); say(args, `Board '${slug}' deleted.${leftLine}`, { slug, action: 'deleted', agent_credentials_left: left }); return 0; }
|
|
1385
1476
|
const archive = join(root, 'kanban', 'boards', '_archived');
|
|
1386
1477
|
mkdirSync(archive, { recursive: true });
|
|
1387
1478
|
let dest = join(archive, `${slug}-${now()}`);
|
|
1388
1479
|
for (let n = 1; existsSync(dest); n += 1) dest = join(archive, `${slug}-${now()}-${n}`);
|
|
1389
1480
|
renameSync(boardDir(root, slug), dest);
|
|
1390
|
-
say(args, `Board '${slug}' archived → ${dest}`, { slug, action: 'archived', new_path: dest });
|
|
1481
|
+
say(args, `Board '${slug}' archived → ${dest}${leftLine}`, { slug, action: 'archived', new_path: dest, agent_credentials_left: left });
|
|
1391
1482
|
return 0;
|
|
1392
1483
|
}
|
|
1393
1484
|
case 'switch': case 'use': {
|
|
@@ -1701,7 +1792,7 @@ function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
|
|
|
1701
1792
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
1702
1793
|
// 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
|
|
1703
1794
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1704
|
-
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`);
|
|
1795
|
+
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'compact', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
1705
1796
|
if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
|
|
1706
1797
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1707
1798
|
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
@@ -1736,7 +1827,7 @@ async function closeSession(board, id) {
|
|
|
1736
1827
|
export function summary(r) {
|
|
1737
1828
|
const parts = [`board ${r.board}:`];
|
|
1738
1829
|
if (r.error) parts.push(`error ${r.error}`);
|
|
1739
|
-
for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices'], ['closed', 'closed sessions'], ['undelivered', 'undelivered (retrying)'], ['subscriptions_ended', 'worker subscriptions to ended sessions ended']]) {
|
|
1830
|
+
for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['retired', 'retired unheld runs'], ['auto_blocked', 'gave up'], ['notified', 'notices'], ['closed', 'closed sessions'], ['undelivered', 'undelivered (retrying)'], ['subscriptions_ended', 'worker subscriptions to ended sessions ended']]) {
|
|
1740
1831
|
if (r[k]?.length) parts.push(`${label} ${r[k].length}`);
|
|
1741
1832
|
}
|
|
1742
1833
|
if (r.promoted) parts.push(`promoted ${r.promoted}`);
|
|
@@ -1808,7 +1899,7 @@ async function answerVerbs(root, log) {
|
|
|
1808
1899
|
* Show/list/comment/create also retain receipts below the slow-log threshold;
|
|
1809
1900
|
* absence from a slow-only log cannot prove those calls met the time bound. */
|
|
1810
1901
|
export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
1811
|
-
if (inBoardGeneration && !readOnly) return generationWork(() => answerOneAdmitted(root, line));
|
|
1902
|
+
if (inBoardGeneration && !readOnly) return generationWork(() => answerOneAdmitted(root, line), 'a card verb');
|
|
1812
1903
|
return answerOneAdmitted(root, line, { readOnly });
|
|
1813
1904
|
}
|
|
1814
1905
|
|
|
@@ -1938,11 +2029,23 @@ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--
|
|
|
1938
2029
|
// handles, nothing more, and the successor takes a fresh epoch that fences any older writer anyway.
|
|
1939
2030
|
say('park: dispatching stopped; admission closes');
|
|
1940
2031
|
stopDispatching();
|
|
1941
|
-
|
|
2032
|
+
// The admitted work a park still waits on is named every 2 s, in the log and as the park's step to the owner, so
|
|
2033
|
+
// a park that outlives the owner's wait says what held it: pin H's on 2026-10-24 (predecessor 25904, 20:38:29Z)
|
|
2034
|
+
// said nothing after admission closed, and the owner's refusal named the generation's first round from 20:28.
|
|
2035
|
+
const waiting = setInterval(() => {
|
|
2036
|
+
const held = admittedWork();
|
|
2037
|
+
if (!held.length) return;
|
|
2038
|
+
say(`park: waiting on ${held.length} admitted work: ${held.join('; ')}`);
|
|
2039
|
+
tellOwner({ type: 'startup-step', phase: 'parking', step: `park: waiting on ${held.join('; ')}` });
|
|
2040
|
+
}, 2_000);
|
|
2041
|
+
waiting.unref?.();
|
|
2042
|
+
try { await parkGeneration(async () => {
|
|
1942
2043
|
say('park: admitted work drained; settling documents');
|
|
1943
2044
|
const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments();
|
|
1944
2045
|
say('park: documents settled');
|
|
1945
|
-
|
|
2046
|
+
// its claims end here, while it still writes: a parked generation outlives its park (dispatch.mjs releaseClaims)
|
|
2047
|
+
try { say(`park: ${releaseClaims(root)} claim(s) released`); } catch (error) { say(`park: claims not released (${error.message}); they hold until their TTL`); }
|
|
2048
|
+
}); } finally { clearInterval(waiting); }
|
|
1946
2049
|
say('park: the writer given up');
|
|
1947
2050
|
retireHeldBoards();
|
|
1948
2051
|
tellOwner({ type: 'parked' });
|
|
@@ -2134,11 +2237,13 @@ async function serve(root, args = {}) {
|
|
|
2134
2237
|
if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
|
|
2135
2238
|
const revision = listenerRevision;
|
|
2136
2239
|
try {
|
|
2137
|
-
await ownCall(()
|
|
2240
|
+
await ownCall(function listenerScan() {
|
|
2138
2241
|
if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
|
|
2139
2242
|
observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
|
|
2140
2243
|
});
|
|
2141
|
-
} catch {
|
|
2244
|
+
} catch (error) {
|
|
2245
|
+
// A scan its stopping generation refused never ran, and this generation scans nothing again: said as that.
|
|
2246
|
+
if (error?.code === GENERATION_STOPPED) { listenerLog(`fleet listener scan not run: ${error.message}`); return; }
|
|
2142
2247
|
// Every caller may schedule this without awaiting it. Preparation refusal leaves source cursors untouched;
|
|
2143
2248
|
// the next admitted scan retries the retained observations, while the owner's public door stays available.
|
|
2144
2249
|
listenerLog('fleet listener scan refused; retained observations wait for a prepared owner call');
|
|
@@ -2157,10 +2262,10 @@ async function serve(root, args = {}) {
|
|
|
2157
2262
|
return listenerReading;
|
|
2158
2263
|
};
|
|
2159
2264
|
fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
|
|
2160
|
-
file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
|
|
2265
|
+
file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key), 'a fleet notice'),
|
|
2161
2266
|
// The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
|
|
2162
2267
|
enrich: event => enrichFleetEvent(root, event),
|
|
2163
|
-
work: ownCall,
|
|
2268
|
+
work: fn => ownCall(fn, 'the listener flush'),
|
|
2164
2269
|
retry: () => scanListeners({ waiting: false }),
|
|
2165
2270
|
});
|
|
2166
2271
|
const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
|
|
@@ -2172,11 +2277,14 @@ async function serve(root, args = {}) {
|
|
|
2172
2277
|
listenerReloadDue = false;
|
|
2173
2278
|
const revision = listenerRevision;
|
|
2174
2279
|
try {
|
|
2175
|
-
const orchestration = await ownCall(() => readHome(root));
|
|
2280
|
+
const orchestration = await ownCall(() => readHome(root), 'the home read');
|
|
2176
2281
|
if (revision !== listenerRevision || stopped) continue;
|
|
2177
2282
|
fleetListeners.configure(orchestration.workflow?.listeners ?? {});
|
|
2178
2283
|
if (fleetListeners.enabled) { scanListeners(); void readListenerRecords(); }
|
|
2179
2284
|
} catch (error) {
|
|
2285
|
+
// A home read its stopping generation refused never ran: nothing is suspended and nothing is retried here, as
|
|
2286
|
+
// this generation serves no further round; its successor reads the configuration itself.
|
|
2287
|
+
if (error?.code === GENERATION_STOPPED) { listenerLog(`fleet listener configuration not read: ${error.message}`); break; }
|
|
2180
2288
|
fleetListeners.suspend();
|
|
2181
2289
|
listenerLog(`fleet listener configuration unread; retained obligations wait: ${String(error.message).slice(0, 300)}`);
|
|
2182
2290
|
// A failed source read is retried by the next round; a failed home read also retains no stale intent.
|
|
@@ -2223,22 +2331,33 @@ async function serve(root, args = {}) {
|
|
|
2223
2331
|
wakeReason = null;
|
|
2224
2332
|
const loopStep = async (name, fn) => { const at = performance.now(); try { return await fn(); } finally { noteStep(`owner loop: ${name}`, performance.now() - at); } };
|
|
2225
2333
|
try {
|
|
2226
|
-
const state = await loopStep('lane state', () => ownCall(() => laneState(root)));
|
|
2334
|
+
const state = await loopStep('lane state', () => ownCall(() => laneState(root), 'the lane state'));
|
|
2227
2335
|
const settings = { ...state.settings, ...override };
|
|
2228
2336
|
interval = settings.interval;
|
|
2229
2337
|
// Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
|
|
2230
2338
|
// on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
|
|
2231
|
-
const published = await loopStep('publish marked', () => ownCall(async () => publishMarked(root)));
|
|
2339
|
+
const published = await loopStep('publish marked', () => ownCall(async () => publishMarked(root), 'publishing marked cards'));
|
|
2232
2340
|
if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
|
|
2233
2341
|
// Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
|
|
2234
2342
|
const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
|
|
2235
|
-
|
|
2236
|
-
|
|
2343
|
+
// The first round's budget bounds its progress: each phase it ends and each door call that settles is a step, said
|
|
2344
|
+
// with the step's budget, so a round slow only on another machine's answers is not ended for its length.
|
|
2345
|
+
const progress = inBoardGeneration && generationServingDue
|
|
2346
|
+
? (step) => tellOwner({ type: 'startup-step', phase: 'serving', step: `completing the first native board round (${step})`, budget_ms: 65000 }) : null;
|
|
2347
|
+
const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true, progress }), 'the dispatch round') : [];
|
|
2348
|
+
if (inBoardGeneration && generationServingDue && !reports.cut && reports.every(report => !report.error)) {
|
|
2237
2349
|
activationSaid(`the first native board round is clean (${dispatching.length} dispatching board(s))`);
|
|
2238
2350
|
generationServingDue = false;
|
|
2239
2351
|
tellOwner({ type: 'serving', pid: process.pid, boards: listBoards(root) });
|
|
2240
2352
|
}
|
|
2241
|
-
//
|
|
2353
|
+
// A board whose dispatching is off runs no round and is delivered nothing; only its close pass runs, so an ended
|
|
2354
|
+
// run's session still closes (dispatching off stops launches, not closes).
|
|
2355
|
+
const resting = listBoards(root).filter((slug) => !dispatching.includes(slug));
|
|
2356
|
+
const closes = resting.length && !stopped ? await loopStep('close pass', () => ownCall(() => closeEnded({ root, slugs: resting, settings }), 'the close pass')) : [];
|
|
2357
|
+
for (const r of closes) {
|
|
2358
|
+
if (r.error || r.closed.length || args.verbose) log(summary(r));
|
|
2359
|
+
for (const [t, to, why] of r.undelivered) log(` close of ${to} for ${t} waits: ${why}`);
|
|
2360
|
+
}
|
|
2242
2361
|
if (dispatching.length) {
|
|
2243
2362
|
round += 1;
|
|
2244
2363
|
// what the round recorded is delivered by a pass of its own, which this loop never waits for
|
|
@@ -2264,8 +2383,11 @@ async function serve(root, args = {}) {
|
|
|
2264
2383
|
for (const [t, to, why, what] of r.undelivered) log(what === 'close' ? ` close of ${to} for ${t} waits: ${why}` : ` notice for ${t} to ${to} not filed: ${why}`);
|
|
2265
2384
|
}
|
|
2266
2385
|
} catch (error) {
|
|
2267
|
-
log(`
|
|
2268
|
-
|
|
2386
|
+
if (error?.code === GENERATION_STOPPED) log(`round not run: ${error.message}`);
|
|
2387
|
+
else {
|
|
2388
|
+
log(`tick failed: ${error.message}`);
|
|
2389
|
+
if (!stopped) await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }], began);
|
|
2390
|
+
}
|
|
2269
2391
|
}
|
|
2270
2392
|
// each round is a line of its own in the slow log, with the steps it spent its time on
|
|
2271
2393
|
recordSlow({ kind: 'call', name: 'dispatch round', ms: Date.now() - began, slowest: null, extra: { steps: takeSteps() } });
|
package/board/custody.mjs
CHANGED
|
@@ -3,7 +3,11 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { actorOf, claimer, event, json, now, tx } from './store.mjs';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** The person a card is assigned to (`person:<Teams principal>`, the form callerPrincipal gives a signed-in person), or
|
|
7
|
+
* null for a card a profile works. A person's card is a dependency on that person: the board starts no session for it,
|
|
8
|
+
* no review judges it, and it closes when that person completes it (workflow `complete`, from `person-done`). */
|
|
9
|
+
export const personOf = (assignee) => /^person:\S+$/.test(String(assignee ?? '')) ? String(assignee) : null;
|
|
10
|
+
export const sessionAddress = s => s?.address ??(s?.machine && s?.harness && s?.session_id ? `sc:${s.machine}:${s.harness}:${s.session_id}` : null);
|
|
7
11
|
export const detachedCard = (db, id) => json(db.prepare('SELECT detached FROM supercode_cards WHERE task_id=?').get(id)?.detached, null);
|
|
8
12
|
export const personCustody = (db, address) => address ? db.prepare('SELECT * FROM supercode_session_custody WHERE address=?').get(address) ?? null : null;
|
|
9
13
|
export const detachedSession = (db, session) => Boolean(session?.detached_at || personCustody(db, sessionAddress(session)));
|