@volter/supercode-orchestrator 0.5.228 → 0.5.229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
@@ -1,10 +1,10 @@
1
1
  // Filesystem/RPC work happens before a board transaction; the ownership check is pure.
2
2
  import { existsSync, realpathSync } from 'node:fs';
3
- import { basename, dirname } from 'node:path';
3
+ import { basename, dirname, posix, win32 } from 'node:path';
4
4
  import { runtimeDoor } from './runtime-door.mjs';
5
5
  import { inspectCheckout } from '@volter/teams/checkout';
6
6
  import { localMachine, normalMachine, commandEnv } from './dispatch.mjs';
7
- import { cardHandleOf } from './store.mjs';
7
+ import { cardHandleOf, json, REVIEW_ALLOCATION_KEY, REVIEW_ALLOCATION_MACHINE, REVIEW_ALLOCATION_PATH } from './store.mjs';
8
8
 
9
9
  // Repository identities are portable input values; the daemon owns their checkout resolution.
10
10
  function repositoryId(value) {
@@ -63,6 +63,35 @@ export async function cardCommitsOn(machine, path, card, branches = [], ref = nu
63
63
  if (!answer || typeof answer !== 'object' || !('at' in answer) || (answer.at !== null && !(Number(answer.at) > 0))) throw new Error(`machine ${name} answered no commit reading`);
64
64
  return { at: answer.at === null ? null : Number(answer.at) };
65
65
  }
66
+ /** Card worktree folder `path` on `machine` as its machine reads it now: its folder as that machine resolves it (its
67
+ * links followed), or null when the folder is not there (this machine's own file reading; another's inspect door, whose
68
+ * git says the folder is gone). Throws when it could not be read. */
69
+ export async function worktreePresent(machine, path) {
70
+ if (!machine || normalMachine(machine) === localMachine()) {
71
+ if (!existsSync(path)) return null;
72
+ try { return realpathSync(path); } catch { return path; }
73
+ }
74
+ try { const facts = await workspaceDoor(machine, 'inspect', path); return facts?.path ?? facts?.root ?? path; }
75
+ catch (error) { if (folderGone(error)) return null; throw error; }
76
+ }
77
+ /** Card worktree `path` on `machine` at its card's close (teams workspaces.mjs retireCardWorktree): removed through git
78
+ * when it is on the card's `branch`, its branch merged and its tree clean, else kept and described. Every machine's
79
+ * folder, this one's included, is read and retired by that machine's daemon through its door, which also forgets a
80
+ * removed checkout in its workspace map: the board reaches the machine layer only through its doors. A machine that
81
+ * cannot answer throws, so the board asks again. */
82
+ export async function retireWorktree(machine, path, { merged = [], branch = null } = {}) {
83
+ const local = !machine || normalMachine(machine) === localMachine();
84
+ const name = local ? localMachine() : normalMachine(machine);
85
+ if (!local && presence?.known === true && presence.offline?.has(name)) throw new Error(`worktree ${path} on ${machine}: machine ${name} is offline`);
86
+ // its two network reads (origin's default branch, its fetch) are each bounded at 30 s there
87
+ const r = await runtimeDoor(local ? null : machine, 'harness.v1.workspaces.retire', { path, merged, branch }, { timeout: 120_000, env: commandEnv() });
88
+ if (r.code !== 0) {
89
+ let refusal = null; try { refusal = JSON.parse(String(r.stderr).trim().split('\n').pop()); } catch { /* said in its own words */ }
90
+ if (refusal?.code === -32601 || /unknown method/.test(refusal?.message ?? '')) throw new Error(`machine ${name}'s daemon has no worktree retiring door (harness.v1.workspaces.retire); its card worktree ${path} is retired once that machine runs a supercode that has it`);
91
+ throw new Error(`worktree ${path} on ${machine}: ${String(refusal?.message ?? (r.stderr || r.stdout || 'its door did not answer')).trim().slice(0, 300)}`);
92
+ }
93
+ return JSON.parse(r.stdout);
94
+ }
66
95
  /** A workspace declaration that asks no machine (scratch, or a repository named by id), else null: a path is declared
67
96
  * through its machine's door (declareWorkspace). */
68
97
  export function plainWorkspace(kind, value, branch = null) {
@@ -140,7 +169,12 @@ export function unrecordedMaking(db, id) {
140
169
  }
141
170
  /** Card `task` as its workspace is resolved: its handle, the branch the board last named for it and, when `claims`, its
142
171
  * claim on that branch (branchClaim). */
172
+ const workspaceTags = (board, task) => {
173
+ const raw = task.tags ?? (task.id ? board.db.prepare('SELECT tags FROM supercode_cards WHERE task_id=?').get(task.id)?.tags : null);
174
+ return Array.isArray(raw) ? raw : raw ? JSON.parse(raw) : task.tenant ? [task.tenant] : [];
175
+ };
143
176
  export const workspaceTask = (board, task, { claims = false } = {}) => ({ ...task, handle: task.handle ?? cardHandleOf(board, task.id),
177
+ tags: workspaceTags(board, task),
144
178
  named_branch: task.id ? lastNamed(board.db, task.id)?.branch ?? null : null,
145
179
  ...(claims ? { branch_claim: task.workspace_kind === 'worktree' ? branchClaim(board.db, task.id, task.branch_name) : null } : {}) });
146
180
  /** A `dir` workspace declared as a folder its machine answers is no git checkout at all (git's "not a git repository (or
@@ -155,6 +189,7 @@ const plainFolder = (declared, error) => {
155
189
  };
156
190
  /** Its machine's git answered that the folder is not there (git's "cannot change to '<path>': No such file or directory"). */
157
191
  const folderGone = (error) => /cannot change to .*: No such file or directory/.test(error?.message ?? '');
192
+ const recordingOptions = task => ({ include_recordings: Array.isArray(task.tags) && task.tags.includes('workspace-recordings') });
158
193
  /** A card's worktree folder deleted from disk (removed through git, or by hand) is made again from the card's branch by
159
194
  * its machine's resolve door (teams workspaces.mjs resolve: its stale registration forgotten, the branch's pushed head
160
195
  * fetched), at the folder it had: a card whose recorded worktree is gone is re-prepared at launch, never guarded. A
@@ -166,11 +201,24 @@ async function remadeDirWorktree(task, declared, error, create) {
166
201
  if (!folderGone(error) || !task.branch_name || basename(dirname(path)) !== '.worktrees') throw error;
167
202
  const checkout = await workspaceDoor(task.machine, 'inspect', dirname(dirname(path))).catch(() => null);
168
203
  if (!checkout?.repository || checkout.linked) throw error;
169
- const made = await workspaceDoor(task.machine, 'resolve', checkout.repository, { branch: task.branch_name, solo: true, card: basename(path), create });
204
+ const made = await workspaceDoor(task.machine, 'resolve', checkout.repository, { branch: task.branch_name, solo: true, card: basename(path), create, ...recordingOptions(task) });
170
205
  if (made.path !== path) throw error;
171
206
  return { ...made, root: made.path };
172
207
  }
173
- export async function resolveWorkspace(task, create = false, { move = false } = {}) {
208
+ export async function resolveWorkspace(task, create = false, { move = false, role = null, cwd = null, reviewWorkspace = null } = {}) {
209
+ if (task.workspace_kind === 'worktree' && role === 'reviewer') {
210
+ // A conversation's cwd is part of its native resume identity, including reviewers predating isolated allocations.
211
+ if (cwd) return { ...await workspaceDoor(task.machine, 'inspect', cwd), path: cwd, reviewWorkspace };
212
+ const beside = reviewWorkspace?.path ?? task.workspace_path;
213
+ const paths = /^(?:[A-Za-z]:[\\/]|\\\\)/.test(beside ?? '') ? win32 : posix;
214
+ const checkout = beside && paths.basename(paths.dirname(beside)) === '.worktrees' ? paths.dirname(paths.dirname(beside)) : null;
215
+ const made = await workspaceDoor(task.machine, 'resolve', task.workspace_anchor, {
216
+ solo: true, detached: true, card: `${task.id}-review`, create, ...(checkout ? { checkout } : {}),
217
+ });
218
+ if (!made.linked || made.branch !== null)
219
+ throw new Error(`workspace resolve on ${task.machine ?? localMachine()} did not provide the detached reviewer allocation for ${task.id}; its answer names branch ${made.branch ?? '(none)'}`);
220
+ return { ...made, reviewWorkspace: { path: made.path, repository: made.repository, machine: normalMachine(task.machine ?? localMachine()) } };
221
+ }
174
222
  const declared = task.workspace_anchor ?? task.workspace_path;
175
223
  if (isWorkspacePath(declared)) {
176
224
  if (task.workspace_kind === 'dir') return workspaceDoor(task.machine, 'inspect', declared)
@@ -196,7 +244,7 @@ export async function resolveWorkspace(task, create = false, { move = false } =
196
244
  // Another machine's folder is read there, and one its git answers is gone is made again through that machine's
197
245
  // resolve door at the same folder, as a missing folder on this machine is by the solo resolve after this branch.
198
246
  const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path).catch((error) => { if (folderGone(error)) return null; throw error; });
199
- if (!cached) return workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create });
247
+ if (!cached) return workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create, ...recordingOptions(task) });
200
248
  if (cached.linked && basename(cached.path) === worktreeFolder(task) && basename(dirname(cached.path)) === '.worktrees' &&
201
249
  cached.repository === repositoryId(declared)) {
202
250
  if (cached.branch === worktreeBranch(task)) {
@@ -206,14 +254,14 @@ export async function resolveWorkspace(task, create = false, { move = false } =
206
254
  // Off its branch, it is put on it only for a start of this card that no other current run shares (`move`), by its
207
255
  // machine's resolve door when that loses nothing, which otherwise answers why not. Every other read refuses as before.
208
256
  if (move) {
209
- const made = await workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create, move });
257
+ const made = await workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create, move, ...recordingOptions(task) });
210
258
  if (made.path === cached.path) return { ...made, root: dirname(dirname(made.path)) };
211
259
  }
212
260
  }
213
261
  throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${worktreeBranch(task)}; retarget its workspace explicitly`);
214
262
  }
215
263
  const solo = task.workspace_kind === 'worktree';
216
- return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create, ...(solo && task.branch_claim ? { claim: task.branch_claim } : {}) });
264
+ return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create, ...(solo ? recordingOptions(task) : {}), ...(solo && task.branch_claim ? { claim: task.branch_claim } : {}) });
217
265
  }
218
266
  /** Whether another card's current run holds `task`'s recorded worktree folder on the same machine. */
219
267
  function heldByAnotherRun(db, task) {
@@ -222,12 +270,12 @@ function heldByAnotherRun(db, task) {
222
270
  return db.prepare(`SELECT t.id,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='worktree' AND t.id!=?`).all(task.id)
223
271
  .some(row => normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && workspacePath(row.workspace_path, local) === mine);
224
272
  }
225
- export async function checkWorkspace(board, task) {
273
+ export async function checkWorkspace(board, task, options = {}) {
226
274
  if (task.workspace_kind === 'scratch') return null;
227
275
  task = workspaceTask(board, task, { claims: true });
228
276
  // Every caller starts this card (a claim, its launch attempt, a person's start), so its own worktree may be put on its
229
277
  // branch, unless another card's current run holds the same folder on this machine; a peer is only ever read.
230
- const facts = await resolveWorkspace(task, false, { move: !heldByAnotherRun(board.db, task) }), peers = [];
278
+ const facts = await resolveWorkspace(task, false, { ...options, move: !heldByAnotherRun(board.db, task) }), peers = [];
231
279
  const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
232
280
  // New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
233
281
  // once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
@@ -248,6 +296,27 @@ const workspacePath = (path, local) => {
248
296
  if (local) { try { return realpathSync(path); } catch { /* a pre-launch reservation may not exist yet */ } }
249
297
  return path.replace(/\/+$/, '');
250
298
  };
299
+ /** Allocations the board made for this card's reviewer slots, retained independently of their current conversation. */
300
+ export function reviewWorkspaces(db, id) {
301
+ // Advance by distinct indexed keys, skipping all repetitions of an allocation in prior review runs.
302
+ return db.prepare(`WITH RECURSIVE allocations(key) AS (
303
+ SELECT (SELECT ${REVIEW_ALLOCATION_KEY} FROM task_runs WHERE task_id=? AND ${REVIEW_ALLOCATION_KEY} IS NOT NULL
304
+ ORDER BY ${REVIEW_ALLOCATION_KEY} LIMIT 1)
305
+ UNION ALL SELECT (SELECT ${REVIEW_ALLOCATION_KEY} FROM task_runs WHERE task_id=? AND ${REVIEW_ALLOCATION_KEY} IS NOT NULL
306
+ AND ${REVIEW_ALLOCATION_KEY}>allocations.key ORDER BY ${REVIEW_ALLOCATION_KEY} LIMIT 1)
307
+ FROM allocations WHERE key IS NOT NULL)
308
+ SELECT metadata FROM task_runs WHERE id IN (SELECT (SELECT id FROM task_runs WHERE task_id=?
309
+ AND ${REVIEW_ALLOCATION_KEY} IS NOT NULL AND ${REVIEW_ALLOCATION_KEY}=allocations.key ORDER BY id DESC LIMIT 1)
310
+ FROM allocations WHERE key IS NOT NULL) ORDER BY id DESC`).all(id, id, id)
311
+ .map(row => { const session = json(row.metadata, {}).supercode; return { ...session.review_workspace, slot: session.slot ?? 'worker' }; });
312
+ }
313
+ /** All board-owned worktrees of a card; an adopted session's arbitrary cwd is never an allocation. */
314
+ export function cardWorktrees(db, task) {
315
+ if (task.workspace_kind !== 'worktree') return [];
316
+ const worktrees = [...(task.workspace_path ? [{ path: task.workspace_path, machine: task.machine, branch: task.branch_name }] : []),
317
+ ...reviewWorkspaces(db, task.id).map(row => ({ ...row, branch: null }))];
318
+ return [...new Map(worktrees.map(row => [`${normalMachine(row.machine ?? localMachine())}:${row.path}`, row])).values()];
319
+ }
251
320
  export function assertWorkspaceOwner(db, task, checked) {
252
321
  if (!checked?.workspaceRoot) return;
253
322
  const machine = normalMachine(task.machine ?? localMachine()), local = machine === localMachine();
@@ -256,6 +325,14 @@ export function assertWorkspaceOwner(db, task, checked) {
256
325
  .find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === machine &&
257
326
  (workspacePath(row.workspace_root, local) === path || workspacePath(row.workspace_path, local) === path || checked.peers?.includes(row.id)));
258
327
  if (conflict) throw new Error(`workspace ${checked.path} is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
328
+ const reviewer = db.prepare(`WITH RECURSIVE owners(id) AS (
329
+ SELECT (SELECT task_id FROM task_runs WHERE ${REVIEW_ALLOCATION_PATH}=? AND ${REVIEW_ALLOCATION_MACHINE}=?
330
+ ORDER BY task_id LIMIT 1)
331
+ UNION ALL SELECT (SELECT task_id FROM task_runs WHERE ${REVIEW_ALLOCATION_PATH}=? AND ${REVIEW_ALLOCATION_MACHINE}=?
332
+ AND task_id>owners.id ORDER BY task_id LIMIT 1) FROM owners WHERE id IS NOT NULL)
333
+ SELECT t.id FROM owners JOIN tasks t ON t.id=owners.id WHERE t.id!=? AND t.workspace_kind='worktree'
334
+ AND t.status NOT IN ('done','archived','cancelled') LIMIT 1`).get(path, machine, path, machine, task.id);
335
+ if (reviewer) throw new Error(`workspace ${checked.path} belongs to reviewer of card ${reviewer.id}; declare worktree:${checked.repository} for a solo worktree`);
259
336
  }
260
337
 
261
338
  export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();