@volter/supercode-orchestrator 0.5.228 → 0.5.230

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
package/board/client.mjs CHANGED
@@ -15,8 +15,10 @@ import { noteCommandError, noteStep } from '@volter/supercode-harness-sdk/slow-l
15
15
  * The run-on-the-machine verbs (BOARD_OPERATION_VERBS: the owner itself, its init, repair and housekeeping, and the
16
16
  * verbs that hold the call open) and help are acts of their own, run here.
17
17
  */
18
- export async function runWorkflow(argv) {
19
- const args = parse(argv);
18
+ export async function runWorkflow(argv, { serveBoardOwner } = {}) {
19
+ // The owner retains malformed or unsupported requests for its manager. The
20
+ // client reads only enough context to address that owner, preserving argv.
21
+ const args = parse(argv, { contextOnly: true });
20
22
  const [verb] = args._;
21
23
  // This home's REST ledger is a local diagnostic, not a card verb. It needs neither a running board owner nor
22
24
  // a workflow/identity/network read just to explain which callers already issued requests.
@@ -29,32 +31,65 @@ export async function runWorkflow(argv) {
29
31
  return 0;
30
32
  }
31
33
  const help = !verb || verb === 'help' || args.help;
34
+ const service = !help && ['serve', 'daemon'].includes(verb);
32
35
  const root = help ? null : homeOf(args);
33
36
  if (!help && verb === 'handover') return sendToOwner(root, argv, args);
34
- if (!help && ['serve', 'daemon'].includes(verb)) {
37
+ // Planning reads the admitted owner's card view. A standalone planner has no incumbent-reader grant and must
38
+ // not open/migrate the backing just to explain eligibility (including a bounded pause or backlog).
39
+ if (!help && verb === 'dispatch' && args['dry-run']) return sendToOwner(root, argv, args);
40
+ if (service) {
35
41
  const { inBoardGeneration } = await import('./generation.mjs');
36
42
  if (inBoardGeneration) {
43
+ // A generation is its owner's service, never its starter's caller, whichever release's owner started it (ADR 0013,
44
+ // "A service carries no caller's identity"): what an older owner passed on is dropped here, so a handover to
45
+ // this code cleans the generation that dispatches.
46
+ (await import('@volter/supercode-teams/service-environment')).dropOwnCallerIdentity();
37
47
  process.send({ type: 'startup-step', step: 'loading the board dispatcher', budget_ms: 8000 });
38
- return (await import('./cli.mjs')).serveBoardGeneration(root, args, argv);
48
+ return (await import('./cli.mjs')).serveBoardGeneration(root, argv);
39
49
  }
40
- return (await import('./generation-owner.mjs')).serveBoardOwner(root, args);
41
50
  }
42
51
  if (help || BOARD_OPERATION_VERBS.has(verb)) {
43
- // The board has one writer. An operation that writes the boards (exclusive, below) takes the home's lock itself
44
- // and so is that writer, refusing while an owner holds it. The rest only read, or are the owner itself (serve), and
45
- // run here as their own act.
46
- const writes = !help && exclusive(verb, args);
52
+ // The board has one writer. Writing operations and initial service admission hold its lock; a service then
53
+ // releases the admission lease so its stable owner can acquire the lifetime lease. Other operations only read.
54
+ const writes = !help && (service || exclusive(verb, args));
55
+ // its own process reads and writes the boards on the incumbent's resting-state reader (local-admission.mjs), from
56
+ // before it takes the lock: taking it fences every board in a transaction (fence.mjs takeEpoch)
57
+ if (!help) await (await import('./local-admission.mjs')).admitLocalRestingReader(root);
47
58
  let lock = null;
48
59
  if (writes) {
49
- try { lock = await takeHome(root); }
60
+ try { lock = await takeHome(root, service); }
50
61
  catch (error) {
51
62
  noteCommandError(error, { phase: 'board ownership' });
52
- process.stderr.write(`supercode workflow: ${verb} writes the boards of ${root}, which have a running owner (${error.message}); stop \`supercode workflow serve\` to run it\n`);
53
- return 1;
63
+ if (error?.code === 'EHELD') {
64
+ if (!service) return sendToOwner(root, argv, args);
65
+ try { parse(argv); }
66
+ catch { return sendToOwner(root, argv, args, { preserveArgv: true }); }
67
+ }
68
+ throw error;
54
69
  }
55
70
  }
56
- const [{ runVerb }, { closeWorld }] = await Promise.all([import('./cli.mjs'), import('../orchestration.mjs')]);
57
- try { return await runVerb(argv); } finally { lock?.release(); await closeWorld(); }
71
+ const [{ runVerb, admitBoardService }, { closeWorld }] = await Promise.all([import('./cli.mjs'), import('../orchestration.mjs')]);
72
+ let failure, failed = false, admission = null, code = null;
73
+ try {
74
+ if (!service) return await runVerb(help ? ['help'] : argv);
75
+ admission = await admitBoardService(argv);
76
+ if (!admission) return 0;
77
+ const firstReaderEpoch = lock.firstReaderEpoch;
78
+ await lock.release(); lock = null;
79
+ code = await serveBoardOwner(admission.root, admission.args, firstReaderEpoch);
80
+ return code;
81
+ }
82
+ catch (error) { failed = true; failure = error; throw error; }
83
+ finally {
84
+ const cleanup = await Promise.allSettled([Promise.resolve().then(() => lock?.release()), Promise.resolve().then(() => closeWorld())]);
85
+ const errors = cleanup.filter(result => result.status === 'rejected').map(result => result.reason);
86
+ if (errors.length) {
87
+ const error = new AggregateError([...(failed ? [failure] : []), ...errors], 'board operation cleanup failed', { cause: failed ? failure : errors[0] });
88
+ admission?.finish(error, 1);
89
+ throw error;
90
+ }
91
+ admission?.finish(failed ? failure : null, code);
92
+ }
58
93
  }
59
94
  return sendToOwner(root, argv, args);
60
95
  }
@@ -70,22 +105,30 @@ function exclusive(verb, args) {
70
105
  }
71
106
 
72
107
  /** Hold the home's lock and take a fencing epoch, as its owner does; rejects when an owner holds the lock. */
73
- async function takeHome(root) {
108
+ async function takeHome(root, service) {
74
109
  mkdirSync(root, { recursive: true });
75
110
  const [{ holdLock }, { takeEpoch }, { listBoards, openBoard }] = await Promise.all([import('./lock-holder.mjs'), import('./fence.mjs'), import('./store.mjs')]);
76
111
  const lock = await holdLock(join(boardHome(root), 'board.lock'), { onLost: (error) => { process.stderr.write(`supercode workflow: ${error.message}; stopping\n`); process.exit(1); } });
77
- takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
78
- return lock;
112
+ try {
113
+ const fresh = service && (await import('./resting-admission.mjs')).pristineHome(root);
114
+ const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
115
+ return { release: lock.release, firstReaderEpoch: fresh ? epoch : null };
116
+ } catch (error) {
117
+ try { await lock.release(); }
118
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'board ownership startup and cleanup failed', { cause: error }); }
119
+ throw error;
120
+ }
79
121
  }
80
122
 
81
- async function sendToOwner(root, argv, args) {
123
+ async function sendToOwner(root, argv, args, { preserveArgv = false } = {}) {
82
124
  const began = performance.now();
83
125
  const sid = processSession();
84
126
  noteStep('board caller session', performance.now() - began);
85
127
  const preparing = performance.now();
86
128
  const harness = sid ? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code') : null;
87
- // a file named `-` is the caller's standard input, read here and sent with the verb
88
- const stdin = [args.file, args['body-file']].includes('-') ? readFileSync(0, 'utf8') : null;
129
+ // Malformed service input travels verbatim for retention; its unsupported file options acquire no stdin.
130
+ // A file named `-` on an ordinary verb is the caller's standard input, read here and sent with the verb.
131
+ const stdin = !preserveArgv && [args.file, args['body-file']].includes('-') ? readFileSync(0, 'utf8') : null;
89
132
  // The caller's own selections travel with the verb, so the owner never answers it with its own: its board (the
90
133
  // board it names, else the one its environment names) and its Teams context (named, else the one selected here).
91
134
  const board = args.board ?? process.env.HERMES_KANBAN_BOARD ?? null;
@@ -94,7 +137,7 @@ async function sendToOwner(root, argv, args) {
94
137
  const asking = performance.now();
95
138
  let answer;
96
139
  try {
97
- answer = await askBoardOwner(root, { argv: withoutHome(argv), board, caller: null, session: sid ? { id: sid, harness } : null, facts, cwd: process.cwd(), stdin });
140
+ answer = await askBoardOwner(root, { argv: preserveArgv ? argv : withoutHome(argv), board, caller: null, session: sid ? { id: sid, harness } : null, facts, cwd: process.cwd(), stdin });
98
141
  } finally { noteStep('board owner answer', performance.now() - asking); }
99
142
  // the owner took the verb and the call ended with no answer: its outcome is unknown, never a plain failure
100
143
  if (answer.unanswered === true) {
@@ -0,0 +1,36 @@
1
+ import { claimer, json, now, view } from './store.mjs';
2
+ import { detachedCard, detachedSession } from './custody.mjs';
3
+ import { ACTIVE_RUN_IDS } from './queries.mjs';
4
+
5
+ const identity = s => [s?.pane ?? null, s?.session_id ?? null, s?.machine ?? null,
6
+ s?.harness ?? null, s?.surface ?? null, s?.address ?? null, s?.pid ?? null, s?.launch_key ?? null];
7
+ const machineName = name => String(name ?? '').split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
8
+ /** A candidate grants no close authority after an await. This guard runs inside the public
9
+ * close's reservation, immediately before each physical mutation, and again before confirmation. */
10
+ export function deferredCloseCurrent(board, row, session, claim) {
11
+ return view(board, db => {
12
+ const latest = db.prepare('SELECT task_id,ended_at,metadata FROM task_runs WHERE id=?').get(row.id);
13
+ const current = json(latest?.metadata, {})?.supercode;
14
+ if (!latest || latest.task_id !== row.task_id || latest.ended_at !== row.ended_at || !current
15
+ || JSON.stringify(identity(current)) !== JSON.stringify(identity(session))
16
+ || detachedCard(db, row.task_id) || detachedSession(db, current) || current.close_confirmed_at
17
+ || current.close_transferred_to || current.released || !current.close_requested
18
+ || current.close_requested !== claim.requested || current.close_claim?.lock !== claimer()
19
+ || current.close_claim?.lock !== claim.lock || current.close_claim?.expires !== claim.expires
20
+ || !(Number(current.close_claim?.expires) > now())
21
+ || current.monitor_held !== claim.monitor || current.retain_after_run !== claim.retained) return false;
22
+ return !db.prepare(`SELECT metadata FROM task_runs WHERE id IN (${ACTIVE_RUN_IDS})`).all().some(run => {
23
+ const s = json(run.metadata, {})?.supercode;
24
+ if (!s) return false;
25
+ const sameMachine = machineName(s.machine) === machineName(session.machine);
26
+ return sameMachine && ((session.pane && s.pane === session.pane)
27
+ || (session.session_id && s.session_id === session.session_id && (s.harness ?? 'claude-code') === (session.harness ?? 'claude-code'))
28
+ || (session.surface === 'headless' && session.pid && s.pid === session.pid));
29
+ });
30
+ });
31
+ }
32
+
33
+ export function requireDeferredClose(board, row, session, claim) {
34
+ if (!deferredCloseCurrent(board, row, session, claim)) throw Object.assign(
35
+ new Error('close dropped: its run, claim, retention or active session binding changed'), { closeSuperseded: true });
36
+ }
@@ -0,0 +1,44 @@
1
+ // A held connection's current card projection. TEMP triggers identify its own writes, including
2
+ // writes without public events. They persist no state: a foreign commit, reload, lost watermark or
3
+ // overlay/schema change requires a bounded rebuild. Rollback marks are harmless extra refreshes.
4
+ import { LIST_CONNECTION, listRevision, readBoundary } from './listing.mjs';
5
+
6
+ export const currentRecordConnection = db => db[LIST_CONNECTION] ?? db;
7
+ const tracked = new WeakMap();
8
+ const RETAINED_CARDS = 4096;
9
+ export function trackCurrentRecords(db) {
10
+ if (tracked.has(db)) return;
11
+ const state = { epoch: 0, floor: 0, reset: 0, rewritten: 0, cards: new Map() };
12
+ tracked.set(db, state);
13
+ db.function('supercode_current_changed', (table, id, rewrite) => {
14
+ const epoch = ++state.epoch;
15
+ if (table === 'custody') state.reset = epoch;
16
+ if (table === 'events' && rewrite) state.rewritten = epoch;
17
+ if (id != null) { state.cards.delete(id); state.cards.set(id, epoch); }
18
+ if (state.cards.size > RETAINED_CARDS) {
19
+ const first = state.cards.keys().next().value;
20
+ state.floor = state.cards.get(first); state.cards.delete(first);
21
+ }
22
+ });
23
+ const specs = [['tasks', 'id', 'cards'], ['supercode_cards', 'task_id', 'cards'],
24
+ ['task_runs', 'task_id', 'runs'], ['task_events', 'task_id', 'events'],
25
+ ['task_links', 'parent_id', 'links'], ['supercode_session_custody', 'task_id', 'custody']];
26
+ for (const [table, key, kind] of specs) for (const [verb, rows] of [['INSERT', ['NEW']], ['UPDATE', ['OLD', 'NEW']], ['DELETE', ['OLD']]]) {
27
+ const calls = rows.flatMap(row => [key, ...(table === 'task_links' ? ['child_id'] : [])]
28
+ .map(column => `SELECT supercode_current_changed('${kind}',${row}.${column},${verb === 'INSERT' ? 0 : 1});`)).join(' ');
29
+ db.exec(`CREATE TEMP TRIGGER IF NOT EXISTS supercode_current_${table}_${verb.toLowerCase()} AFTER ${verb} ON main.${table} BEGIN ${calls} END`);
30
+ }
31
+ }
32
+
33
+ /** Snapshot changes at one read boundary; never trust row hints across a foreign commit. */
34
+ export function currentRecordChanges(db, previous = null) {
35
+ return readBoundary(db, () => {
36
+ const revision = listRevision(db), connection = currentRecordConnection(db), state = tracked.get(connection);
37
+ const token = { ...revision, epoch: state?.epoch ?? null, rewritten: state?.rewritten ?? null };
38
+ const reset = !previous || !state || previous.connection !== token.connection || previous.data !== token.data
39
+ || previous.mainSchema !== token.mainSchema || previous.tempSchema !== token.tempSchema
40
+ || previous.epoch == null || previous.epoch < state.floor || previous.epoch < state.reset;
41
+ const cards = reset ? null : previous.epoch === state.epoch ? [] : [...state.cards].filter(([, epoch]) => epoch > previous.epoch).map(([id]) => id);
42
+ return { token, reset, cards, eventsRewritten: reset || previous.rewritten !== token.rewritten };
43
+ });
44
+ }
package/board/custody.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { latestEvent } from './queries.mjs';
1
2
  // Native identity survives a handback. Custody decides what this board may do with it.
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { hostname } from 'node:os';
@@ -73,7 +74,7 @@ export function detachSession(board, id, person) {
73
74
  return sessionAddress(s) && sessionAddress(s) !== address && s.close_requested && !s.close_confirmed_at && !s.close_transferred_to && !s.detached_at;
74
75
  })) throw new Error('another native session on this card has an unresolved close; settle that receipt before detaching');
75
76
  assertNoSessionOperation(db, id, address);
76
- const adoption = db.prepare("SELECT id,kind,payload FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted','adoption_failed','adoption_claimed') ORDER BY id DESC LIMIT 1").get(id);
77
+ const adoption = latestEvent(db, id, ['dispatch_held','dispatch_released','session_adopted','adoption_failed','adoption_claimed'], 'id,kind,payload');
77
78
  const request = json(adoption?.payload, {});
78
79
  if (adoption?.kind === 'adoption_claimed' && request.lock && operationAlive({ owner: request.lock })) throw new Error('an adoption is already being bound; wait for its answer before detaching');
79
80
  for (const other of db.prepare('SELECT r.id,r.task_id,r.metadata FROM task_runs r JOIN tasks t ON t.current_run_id=r.id WHERE t.id<>?').all(id)) {