@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,21 +1,26 @@
1
+ import { nativeJsonEqual } from '@volter/supercode-interchange/native-json';
2
+ import { measureWork, registerWorkOperations, stopWorkChild } from "@volter/supercode-harness-sdk/work-ledger";
3
+ registerWorkOperations([{"kind":"pass","operation":"board-owner.record"},{"kind":"pass","operation":"board-owner.activate"},{"kind":"pass","operation":"board-owner.prepare"},{"kind":"pass","operation":"board-owner.rollback"},{"kind":"pass","operation":"board-owner.switch"},{"kind":"door","operation":"board-owner.control"},{"kind":"door","operation":"board-owner.command"}]);
4
+ import { registerWorkService, closeWorkLedger } from '@volter/supercode-harness-sdk/work-ledger';
1
5
  // The stable board owner: one OS lock and public door, with replaceable, fenced dispatcher generations behind it.
2
6
  // A request already sent to a generation is never replayed. Only unsent requests wait at the handover boundary.
3
7
  import { readSourceCandidate } from '@volter/supercode-teams/source-selection';
4
8
  import { sourceLockPath } from '@volter/supercode-teams/source-lock';
9
+ import { dropOwnCallerIdentity } from '@volter/supercode-teams/service-environment';
5
10
  import { spawn } from 'node:child_process';
6
11
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
- import { join, resolve } from 'node:path';
12
+ import { isAbsolute, join, resolve } from 'node:path';
8
13
  import { fileURLToPath } from 'node:url';
9
14
  import { randomUUID } from 'node:crypto';
10
15
  import { boardHome, boardOwnerSocket } from '@volter/teams/board-owner';
11
16
  import { retainedReceipt } from '@volter/supercode-teams/release-selection';
12
- import { sourceTransaction } from '@volter/supercode-teams/serving-source-transaction';
17
+ import { sourceTransaction, readBoardSourceReservation } from '@volter/supercode-teams/serving-source-transaction';
13
18
  import { holdLock } from './lock-holder.mjs';
14
19
  import { openOwnerDoor } from './owner-door.mjs';
15
- import { holdOwnerHealth } from './owner-health.mjs';
16
- import { holdWorkflow } from './workflow.mjs';
17
- import { knowLocalMachine, localMachine } from './dispatch.mjs';
18
- import { parse } from './args.mjs';
20
+ import { holdWorkflow } from './owner-interface.mjs';
21
+ import { knowLocalMachine, localMachine } from './owner-interface.mjs';
22
+ import { parse } from './owner-interface.mjs';
23
+ import { publishStandaloneDeclaration } from './standalone-declaration.mjs';
19
24
 
20
25
  const unknown = () => ({ code: 1, unanswered: true, stdout: '', stderr: 'the board generation ended after taking this request; its outcome is unknown\n' });
21
26
  const failed = error => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error.message}\n` });
@@ -25,21 +30,45 @@ const failed = error => ({ code: 1, stdout: '', stderr: `supercode workflow: ${e
25
30
  const READS = new Set(['show', 'list', 'ls', 'context', 'brief', 'runs', 'log', 'stats', 'assignees', 'attachments', 'attachment-read', 'notify-list']);
26
31
  const READ_MS = 8000;
27
32
 
28
- export async function serveBoardOwner(root, args) {
33
+ export async function serveBoardOwner(root, args, firstReaderEpoch) {
34
+ // Service admission (runWorkflow) was the starting caller's act. The owner and every generation, lock holder and
35
+ // command it starts afterwards act as the install, never as that caller (ADR 0080, "Resident startup"; ADR 0013,
36
+ // "A service carries no caller's identity"): a card agent's context kept here would refuse every remote launch once
37
+ // that agent ended.
38
+ dropOwnCallerIdentity();
29
39
  root = boardHome(root);
40
+ registerWorkService({role:'board-owner',scope:{kind:'install',id:root}});
30
41
  mkdirSync(root, { recursive: true });
42
+ // The service owns its working directory; the file arguments stay the caller's. A path the starter named is bound
43
+ // against the starter's directory here, before the owner leaves it for its home: a removed starter directory would
44
+ // otherwise fail every generation it starts (and the harness service each runs) at process.cwd(), so no handover
45
+ // could prepare a successor.
46
+ const pidfile = args.pidfile ? resolve(args.pidfile) : null;
47
+ process.chdir(root);
31
48
  const log = line => process.stdout.write(`${new Date().toISOString()} board owner: ${line}\n`);
32
- let stopping = false, active = null, previous = null, transition = null, routing = null, pending = null, recoveryFailure = null;
49
+ let stopping = false, active = null, previous = null, transition = null, routing = null, pending = null, recoveryFailure = null, lastRestingReader = null;
33
50
  const generations = new Set(), waiting = new Set();
51
+ // Generations told to end that outlived TERM and KILL, by pid: named in the generation record and the status until
52
+ // their process exits, so a superseded worker is never left running silently.
53
+ const unended = new Map();
34
54
  const recordPath = join(root, 'board-generation.json');
35
- const record = child => {
55
+ const record = (child, stopped = false) => {
56
+ return measureWork("pass", "board-owner.record", () => {
36
57
  const temporary = `${recordPath}.${randomUUID()}.tmp`;
37
- writeFileSync(temporary, JSON.stringify({ version: 1, owner: process.pid, active: child?.process.pid ?? null,
38
- selected: child?.selected ?? null, generation: child?.id ?? null, previous: previous && !previous.ended ? previous.process.pid : null, at_ms: Date.now() }) + '\n', { mode: 0o600 });
58
+ writeFileSync(temporary, JSON.stringify({ version: 1, owner: stopped ? null : process.pid, active: child?.process.pid ?? null,
59
+ selected: child?.selected ?? null, generation: child?.id ?? null, previous: stopped ? null : previous && !previous.ended ? previous.process.pid : null, unended: [...unended.values()], restingReaderProtocol: 1, lastRestingReader, stopped, at_ms: Date.now() }) + '\n', { mode: 0o600 });
39
60
  renameSync(temporary, recordPath);
61
+
62
+ });
40
63
  };
41
64
  const changed = () => { for (const done of waiting) done(); waiting.clear(); };
42
65
  const lock = await holdLock(join(root, 'board.lock'), { onLost: error => { log(error.message); process.exit(1); } });
66
+ // Only the lock holder lends actual acknowledged reader history. A candidate selected before writer grant
67
+ // cannot become this record; an older writer invalidates it before activation.
68
+ try {
69
+ const prior = JSON.parse(readFileSync(recordPath, 'utf8'));
70
+ if (prior.version === 1 && prior.restingReaderProtocol === 1 && prior.lastRestingReader?.version === 1) lastRestingReader = prior.lastRestingReader;
71
+ } catch { /* no retained reader: conservative legacy admission */ }
43
72
  await knowLocalMachine();
44
73
  const entry = resolve(process.env.SUPERCODE_ORCHESTRATOR_ENTRY || fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url)));
45
74
  const start = selected => {
@@ -57,7 +86,7 @@ export async function serveBoardOwner(root, args) {
57
86
  stdio: ['ignore', 'inherit', 'inherit', 'ipc'], serialization: 'advanced',
58
87
  env,
59
88
  });
60
- const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false, health: null, workflows: new Map(),
89
+ const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false, workflows: new Map(),
61
90
  heard: Date.now(), silenceSaid: false };
62
91
  generation.exited = new Promise(done => { processChild.once('exit', done); processChild.once('error', done); });
63
92
  generations.add(generation);
@@ -93,8 +122,8 @@ export async function serveBoardOwner(root, args) {
93
122
  generation.ended = true;
94
123
  // IPC loss revokes the child but does not prove its process ended. End that exact owned child before recovery.
95
124
  if (processChild.exitCode === null && processChild.signalCode === null) {
96
- processChild.kill('SIGTERM');
97
- const bound = setTimeout(() => processChild.kill('SIGKILL'), 2000);
125
+ stopWorkChild(processChild, () => processChild.kill('SIGTERM'), { signal: 'SIGTERM' });
126
+ const bound = setTimeout(() => stopWorkChild(processChild, () => processChild.kill('SIGKILL'), { signal: 'SIGKILL' }), 2000);
98
127
  generation.exited.finally(() => clearTimeout(bound));
99
128
  }
100
129
  generations.delete(generation);
@@ -120,6 +149,21 @@ export async function serveBoardOwner(root, args) {
120
149
  processChild.on('message', frame => {
121
150
  generation.heard = Date.now(); generation.silenceSaid = false;
122
151
  if (!frame || typeof frame.type !== 'string') return;
152
+ if (frame.type === 'resting-writer-granted') {
153
+ try {
154
+ const proof = generation.events.get('prepared');
155
+ if (!generation.writerRequested || generation.ended || frame.generation !== generation.id ||
156
+ typeof frame.request !== 'string' || frame.request.length > 128 || proof?.restingReaderProtocol !== 1 || !proof.resting)
157
+ throw new Error('generation has no matching prepared resting-state writer grant');
158
+ const next = { version: 1, selected: generation.selected, resting: proof.resting };
159
+ if (!nativeJsonEqual(lastRestingReader, next)) lastRestingReader = next;
160
+ record(generation);
161
+ tell({ type: 'resting-reader-recorded', request: frame.request, generation: generation.id });
162
+ } catch (error) {
163
+ try { tell({ type: 'resting-reader-recorded', request: frame.request, generation: generation.id, error: error.message }); } catch { /* child disconnected */ }
164
+ }
165
+ return;
166
+ }
123
167
  if (frame.type === 'startup-step') {
124
168
  generation.startupStep = frame.step;
125
169
  if (Number.isSafeInteger(frame.budget_ms) && frame.budget_ms >= 1 && frame.budget_ms <= 65000)
@@ -127,7 +171,6 @@ export async function serveBoardOwner(root, args) {
127
171
  return;
128
172
  }
129
173
  if (frame.type === 'answer') { generation.calls.get(frame.id)?.(frame.answer); generation.calls.delete(frame.id); changed(); return; }
130
- if (frame.type === 'health') { generation.health = frame; if (routing === generation) holdOwnerHealth(frame.boards, frame.round); return; }
131
174
  if (frame.type === 'workflow') { generation.workflows.set(frame.root, frame.entry); if (routing === generation) holdWorkflow(frame.root, frame.entry); return; }
132
175
  const waiter = generation.events.get(frame.type);
133
176
  if (waiter?.done) { clearTimeout(waiter.timer); waiter.done(frame); }
@@ -136,11 +179,32 @@ export async function serveBoardOwner(root, args) {
136
179
  processChild.once('error', ended); processChild.once('exit', ended); processChild.once('disconnect', ended);
137
180
  return generation;
138
181
  };
139
- const terminate = async generation => {
140
- generation.process.kill('SIGTERM');
141
- const timer = setTimeout(() => generation.process.kill('SIGKILL'), 2000);
142
- try { await generation.exited; } finally { clearTimeout(timer); }
143
- };
182
+ // Ending a generation: TERM, KILL 2 s later, then 3 s for its exit. Each end is said in this owner's log with why;
183
+ // one that has not exited 5 s after TERM is said, kept in `unended` (record and status) until it exits, and resolves
184
+ // false, so no caller waits on it forever. Ending is idempotent: a generation already ended answers at once.
185
+ const terminate = (generation, why) => generation.ending ??= (async () => {
186
+ const child = generation.process, began = Date.now();
187
+ const exited = ms => {
188
+ let timer;
189
+ return Promise.race([generation.exited.then(() => true), new Promise(done => { timer = setTimeout(() => done(false), ms); })]).finally(() => clearTimeout(timer));
190
+ };
191
+ const name = `generation ${generation.id} (pid ${child.pid})`;
192
+ if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) { await generation.exited; return true; }
193
+ stopWorkChild(child, () => child.kill('SIGTERM'), { signal: 'SIGTERM' });
194
+ let by = 'TERM';
195
+ if (!await exited(2000)) {
196
+ by = 'KILL'; stopWorkChild(child, () => child.kill('SIGKILL'), { signal: 'SIGKILL' });
197
+ if (!await exited(3000)) {
198
+ unended.set(child.pid, { pid: child.pid, generation: generation.id, why, since_ms: began });
199
+ log(`${name} did not exit ${Date.now() - began} ms after TERM and KILL (${why}); it is still running and is named in board-generation.json until it exits`);
200
+ generation.exited.then(() => { if (unended.delete(child.pid)) { log(`${name} exited ${Date.now() - began} ms after it was told to end`); record(active); } });
201
+ generation.ending = null; record(active);
202
+ return false;
203
+ }
204
+ }
205
+ log(`${name} ended by ${by} in ${Date.now() - began} ms: ${why}`);
206
+ return true;
207
+ })();
144
208
  // Whether every request already sent to a generation was answered (or the generation ended) within the bound.
145
209
  // New requests are held at the door meanwhile, so nothing more is sent to it.
146
210
  const drained = (generation, ms = 8000) => {
@@ -150,8 +214,11 @@ export async function serveBoardOwner(root, args) {
150
214
  }), new Promise(done => { timer = setTimeout(() => done(false), ms); })]).finally(() => clearTimeout(timer));
151
215
  };
152
216
  const activate = async generation => {
217
+ return measureWork("pass", "board-owner.activate", async () => {
153
218
  if (stopping || generation.ended) throw new Error('cannot activate an ended board generation');
219
+ if (generation.events.get('prepared')?.restingReaderProtocol !== 1) lastRestingReader = null;
154
220
  record(generation);
221
+ generation.writerRequested = true;
155
222
  generation.events.delete('routable'); generation.events.delete('serving');
156
223
  generation.tell({ type: 'activate' });
157
224
  log(`generation ${generation.id} (pid ${generation.process.pid}) activates`);
@@ -171,13 +238,26 @@ export async function serveBoardOwner(root, args) {
171
238
  // before asking each store for stats; an assumed default would refuse an otherwise serving empty home.
172
239
  const boards = serving.boards;
173
240
  if (!Array.isArray(boards)) throw new Error('successor board inventory was unreadable; predecessor retained');
174
- await read(['declaration', '--json']);
241
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
242
+ const count = value => Number.isSafeInteger(value) && value >= 0;
243
+ const counts = value => object(value) && Object.values(value).every(count);
244
+ const grouped = value => object(value) && Object.values(value).every(counts);
245
+ const declaration = await read(['declaration', '--json']);
246
+ if (!object(declaration) || typeof declaration.board !== 'string'
247
+ || ![null, 'sqlite', 'files', 'ztrack'].includes(declaration.backing)
248
+ || !(declaration.declared === null || object(declaration.declared)))
249
+ throw new Error('successor declaration readback was not a board declaration; predecessor retained');
175
250
  for (const slug of boards) {
176
251
  if (typeof slug !== 'string') throw new Error('successor board inventory has no slug; predecessor retained');
177
- await read(['stats', '--json', '--board', slug]);
252
+ const stats = await read(['stats', '--json', '--board', slug]);
253
+ if (!object(stats) || !counts(stats.by_status) || !counts(stats.by_lane)
254
+ || !grouped(stats.by_assignee) || !grouped(stats.by_machine)
255
+ || !(stats.oldest_ready_age_seconds === null || Number.isFinite(stats.oldest_ready_age_seconds))
256
+ || !object(stats.backlog) || !count(stats.backlog.total)
257
+ || !counts(stats.backlog.by_assignee) || !counts(stats.backlog.by_machine) || !Number.isFinite(stats.now))
258
+ throw new Error(`successor ${slug} stats readback was not board statistics; predecessor retained`);
178
259
  }
179
260
  active = generation; routing = generation; recoveryFailure = null;
180
- if (generation.health) holdOwnerHealth(generation.health.boards, generation.health.round);
181
261
  for (const [at, value] of generation.workflows) holdWorkflow(at, value);
182
262
  changed();
183
263
  log(`generation ${generation.id} routed (pid ${generation.process.pid})`);
@@ -193,13 +273,26 @@ export async function serveBoardOwner(root, args) {
193
273
  }
194
274
  throw error;
195
275
  }
276
+ publishStandaloneDeclaration(root, generation.selected);
196
277
  log(`generation ${generation.id} serves (pid ${generation.process.pid})`);
278
+ // Its first clean round recorded, this generation is the board's one writer. No path resumes a parked or superseded
279
+ // generation (a rollback after parking starts a fresh one from the predecessor's selection), so every other
280
+ // generation ends here, never waiting on the transaction's finish: only a prepared candidate not yet activated stays.
281
+ for (const other of [...generations]) {
282
+ if (other === generation || (pending?.phase === 'prepared' && other === pending.candidate)) continue;
283
+ await terminate(other, `superseded by generation ${generation.id} (pid ${generation.process.pid}) after its first clean round`);
284
+ }
285
+ record(generation);
197
286
  return generation;
287
+
288
+ });
198
289
  };
199
- const status = () => ({ version: 1, protocol: 1, owner: process.pid, state: routing ? 'serving' : 'transitioning',
200
- generation: active?.id, pid: active?.process.pid, selected: active?.selected, previous: previous?.selected ?? null,
201
- transaction: pending?.id ?? null, phase: pending?.phase ?? null, failure: recoveryFailure?.message ?? null });
290
+ const status = () => ({ version: 1, protocol: 1, restingReaderProtocol: 1, lastRestingReader, owner: process.pid, state: routing ? 'serving' : 'transitioning',
291
+ firstReaderEpoch: !active && !lastRestingReader ? firstReaderEpoch : null,
292
+ generation: active?.id, pid: active?.process.pid, selected: active?.selected, previous: previous?.selected ?? pending?.before ?? null,
293
+ transaction: pending?.id ?? null, phase: pending?.phase ?? null, failure: recoveryFailure?.message ?? null, unended: [...unended.values()] });
202
294
  const prepare = async (id, selected) => {
295
+ return measureWork("pass", "board-owner.prepare", async () => {
203
296
  if (pending) {
204
297
  if (pending.id === id) {
205
298
  if (JSON.stringify(pending.candidate.selected) !== JSON.stringify(selected)) throw new Error('board transaction already names different inputs');
@@ -218,27 +311,33 @@ export async function serveBoardOwner(root, args) {
218
311
  // modules' source, which is then compared.
219
312
  const same = proof.format && held?.format ? proof.format === held.format || (Array.isArray(proof.readableBy) && proof.readableBy.includes(held.format)) : Boolean(proof.storage) && proof.storage === held?.storage;
220
313
  if (!same) throw new Error('board storage contract differs; predecessor retained without opening a writer');
221
- pending = { id, candidate, predecessor: active, older: previous, phase: 'prepared' };
314
+ pending = { id, candidate, predecessor: active, before: active.selected, older: previous, phase: 'prepared' };
222
315
  return status();
223
- } catch (error) { await terminate(candidate); throw error; }
316
+ } catch (error) { await terminate(candidate, `its preparation failed: ${error.message}`); throw error; }
317
+
318
+ });
224
319
  };
225
320
  const rollback = async () => {
321
+ return measureWork("pass", "board-owner.rollback", async () => {
226
322
  const held = pending;
227
323
  if (!held) return status();
228
- await terminate(held.candidate);
324
+ await terminate(held.candidate, 'the handover rolls back');
229
325
  previous = held.older;
230
326
  if (held.phase !== 'prepared') {
231
327
  routing = null;
232
328
  // A timed-out park may still be executing. End that exact child before a fresh predecessor takes an epoch;
233
329
  // an activate sent to a child still parking can otherwise race its final writer revocation.
234
- await terminate(held.predecessor);
235
- const replacement = start(held.predecessor.selected);
330
+ if (held.predecessor) await terminate(held.predecessor, 'the handover rolls back after it parked; a fresh generation starts from its selection');
331
+ const replacement = start(held.before);
236
332
  try { await replacement.wait('prepared'); await activate(replacement); }
237
- catch (error) { await terminate(replacement); throw error; }
333
+ catch (error) { await terminate(replacement, `its activation failed: ${error.message}`); throw error; }
238
334
  } else if (!held.predecessor.ended) { active = routing = held.predecessor; changed(); }
239
335
  pending = null; record(active); return status();
336
+
337
+ });
240
338
  };
241
339
  const switchPrepared = async () => {
340
+ return measureWork("pass", "board-owner.switch", async () => {
242
341
  const held = pending;
243
342
  if (held.phase === 'activated' && routing === held.candidate) return status();
244
343
  if (held.phase !== 'prepared' || active !== held.predecessor) throw new Error('board preparation is no longer current');
@@ -256,44 +355,57 @@ export async function serveBoardOwner(root, args) {
256
355
  await held.predecessor.wait('parked'); previous = held.predecessor;
257
356
  await activate(held.candidate); held.phase = 'activated'; return status();
258
357
  } catch (error) { await rollback(); throw error; }
358
+
359
+ });
259
360
  };
260
- const control = async parsed => {
361
+ // A handover's file operands are its caller's, read against the directory its request carries (board-owner.mjs).
362
+ const control = async (parsed, cwd) => {
363
+ // Every operand, absolute or relative, goes through one resolve, so a selection compares in one canonical form.
364
+ const callerPath = value => {
365
+ const base = typeof cwd === 'string' && isAbsolute(cwd) ? cwd : null;
366
+ if (!base && !isAbsolute(value)) throw new Error('a relative handover path needs the caller\'s working directory');
367
+ return base ? resolve(base, value) : resolve(value);
368
+ };
369
+ return measureWork("door", "board-owner.control", async () => {
261
370
  const action = parsed.action ?? 'replace', id = parsed.transaction;
262
371
  if (action === 'status') return status();
263
372
  if (typeof id !== 'string' || !id) throw new Error('board handover needs --transaction');
264
373
  if (action === 'prepare' || action === 'replace') {
265
374
  if (!parsed.entry || !parsed.supercode) throw new Error('handover needs --entry and --supercode from one prepared machine release');
266
- await prepare(id, { entry: resolve(parsed.entry), native: resolve(parsed.supercode), candidate: parsed.candidate ? resolve(parsed.candidate) : null, release: parsed.release ? resolve(parsed.release) : null });
375
+ await prepare(id, { entry: callerPath(parsed.entry), native: callerPath(parsed.supercode), candidate: parsed.candidate ? callerPath(parsed.candidate) : null, release: parsed.release ? callerPath(parsed.release) : null });
267
376
  } else if (pending?.id !== id) throw new Error('this board holds no matching handover transaction');
268
- if (pending) sourceTransaction(id, pending.candidate.selected, pending.predecessor.selected, action === 'replace' ? 'activate' : action, process.env, 'board');
377
+ if (pending) sourceTransaction(id, pending.candidate.selected, pending.before, action === 'replace' ? 'activate' : action, process.env, 'board');
269
378
  if (action === 'activate' || action === 'replace') await switchPrepared();
270
379
  if (action === 'rollback' || action === 'abort') return rollback();
271
380
  if (action === 'finish' || action === 'replace') {
272
- sourceTransaction(id, pending.candidate.selected, pending.predecessor.selected, 'finish', process.env, 'board');
381
+ sourceTransaction(id, pending.candidate.selected, pending.before, 'finish', process.env, 'board');
273
382
  if (pending.phase !== 'activated') throw new Error('board successor was never activated');
274
383
  if (pending.candidate.ended) {
275
- await terminate(pending.candidate);
384
+ await terminate(pending.candidate, 'the activated successor ended before the handover finished');
276
385
  const replacement = start(pending.candidate.selected);
277
386
  try { await replacement.wait('prepared'); await activate(replacement); pending.candidate = replacement; }
278
- catch (error) { await terminate(replacement); throw error; }
387
+ catch (error) { await terminate(replacement, `its activation failed: ${error.message}`); throw error; }
279
388
  }
280
389
  if (routing !== pending.candidate || active.ended) throw new Error('board successor is not serving; handover cannot finish');
281
- // The parked predecessor is no rollback once the transaction is finished: no path resumes a parked generation
282
- // (a rollback after parking starts a fresh one from its selection), so every generation but the serving one ends
283
- // here. `previous` keeps naming the selection it served.
284
- for (const retained of generations) if (retained !== active) await terminate(retained);
390
+ // The successor's first clean round already ended its predecessor (activate); any generation still held but the
391
+ // serving one ends here too. `previous` keeps naming the selection it served.
392
+ for (const retained of [...generations]) if (retained !== active) await terminate(retained, `the handover ${id} finished`);
393
+ publishStandaloneDeclaration(root, active.selected);
285
394
  pending = null; record(active);
286
395
  } else if (!['prepare', 'activate'].includes(action)) throw new Error('unknown board handover action');
287
396
  return status();
397
+
398
+ });
288
399
  };
289
400
  const answer = async line => {
401
+ return measureWork("door", "board-owner.command", async () => {
290
402
  let request;
291
403
  try { request = JSON.parse(line); } catch { /* generation answers malformed input */ }
292
404
  if (request?.v === 1 && Array.isArray(request.argv) && request.argv[0] === 'handover') {
293
405
  const parsed = parse(request.argv);
294
406
  if (parsed.action === 'status') return { code: 0, stdout: JSON.stringify(status()) + '\n', stderr: '' };
295
407
  if (transition) return failed(new Error('a board handover is already in progress'));
296
- transition = control(parsed);
408
+ transition = control(parsed, request.cwd);
297
409
  try { return { code: 0, stdout: JSON.stringify(await transition) + '\n', stderr: '' }; }
298
410
  catch (error) { return failed(error); }
299
411
  finally { transition = null; changed(); }
@@ -316,34 +428,57 @@ export async function serveBoardOwner(root, args) {
316
428
  if (!serving.silenceSaid) { serving.silenceSaid = true; log(`generation ${serving.id} (pid ${serving.process.pid}) did not answer a ${verb} within ${READ_MS / 1000} s; its last message came ${quiet} s before`); }
317
429
  return failed(new Error(`the board's serving generation (pid ${serving.process.pid}) did not answer this ${verb} within ${READ_MS / 1000} s (its last message to the board owner came ${quiet} s before); nothing was read: ask again`));
318
430
  });
431
+
432
+ });
319
433
  };
320
434
  let closeDoor;
435
+ let failure, hadFailure = false;
321
436
  const stop = () => {
322
437
  if (stopping) return;
323
438
  stopping = true; changed();
324
- for (const generation of generations) if (!generation.ended) generation.process.kill('SIGTERM');
439
+ for (const generation of generations) if (!generation.ended) stopWorkChild(generation.process, () => generation.process.kill('SIGTERM'), { signal: 'SIGTERM' });
325
440
  };
326
441
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);
327
442
  try {
328
443
  // Only this lock's holder publishes/removes the public socket, for its entire lifetime.
329
444
  rmSync(boardOwnerSocket(root), { force: true });
330
445
  closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer, log, readListings: false });
331
- const first = start({ entry, native: process.env.SUPERCODE_BIN, candidate: process.env.SUPERCODE_SOURCE_CANDIDATE || null, release: process.env.SUPERCODE_SOURCE_CANDIDATE ? null : retainedReceipt(entry) });
446
+ const selected = { entry, native: process.env.SUPERCODE_BIN, candidate: process.env.SUPERCODE_SOURCE_CANDIDATE || null, release: process.env.SUPERCODE_SOURCE_CANDIDATE ? null : retainedReceipt(entry) };
447
+ const reservation = await readBoardSourceReservation(root);
448
+ const first = start(reservation?.selected ?? selected);
332
449
  await first.wait('prepared');
450
+ if (reservation?.phase === 'activated') pending = { id: reservation.id, candidate: first,
451
+ predecessor: null, before: reservation.before, older: null, phase: 'activated' };
333
452
  await activate(first);
453
+ if (reservation?.phase === 'prepared') await prepare(reservation.id, reservation.target);
334
454
  if (typeof process.send === 'function' && Number(process.env.SUPERCODE_BOARD_START_OWNER) === process.ppid)
335
455
  process.send({ type: 'board-owner-ready', owner: process.pid });
336
- if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
456
+ if (pidfile) writeFileSync(pidfile, `${process.pid}\n`);
337
457
  while (!stopping) await new Promise(done => waiting.add(done));
458
+ } catch (error) {
459
+ hadFailure = true; failure = error; throw error;
338
460
  } finally {
339
- stop();
340
- await Promise.all([...generations].map(terminate));
341
- closeDoor?.();
342
- rmSync(boardOwnerSocket(root), { force: true });
343
- try { if (JSON.parse(readFileSync(recordPath, 'utf8')).owner === process.pid) rmSync(recordPath); } catch { /* absent */ }
344
- lock.release();
461
+ const errors = [];
462
+ const attempt = async work => { try { await work(); } catch (error) { errors.push(error); } };
463
+ await attempt(() => stop());
464
+ for (const result of await Promise.allSettled([...generations].map(generation => terminate(generation, 'the board owner stops'))))
465
+ if (result.status === 'rejected') errors.push(result.reason);
466
+ await attempt(() => closeDoor?.());
467
+ await attempt(() => rmSync(boardOwnerSocket(root), { force: true }));
468
+ await attempt(() => {
469
+ try { if (JSON.parse(readFileSync(recordPath, 'utf8')).owner === process.pid) {
470
+ if (lastRestingReader || unended.size) record(null, true);
471
+ else rmSync(recordPath);
472
+ } }
473
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
474
+ });
475
+ // Releasing the lock is attempted even when the public door failed to close. Join both owned exits before
476
+ // the final checkpoint, so their known completions and durations cannot remain in flight in a closed row.
477
+ await attempt(() => lock.release());
345
478
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, stop);
346
- if (args.pidfile) rmSync(args.pidfile, { force: true });
479
+ if (pidfile) await attempt(() => rmSync(pidfile, { force: true }));
480
+ await closeWorkLedger();
481
+ if (errors.length) throw new AggregateError([...(hadFailure ? [failure] : []), ...errors], 'board owner shutdown failed', { cause: hadFailure ? failure : errors[0] });
347
482
  }
348
483
  return 0;
349
484
  }
@@ -1,3 +1,5 @@
1
+ import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
2
+ const execFileSync = (...args) => measureSyncChild(() => workLedgerNativeExecFileSync(...args));
1
3
  // THE BOARD DOCUMENT IN GIT (company RFC 0025 decision 3; ADR 0001 points 2 and 3): when the board's document lives in a
2
4
  // git checkout and the board says so (`"commit": true` in its ztrack.json), each write the manager does not make (its
3
5
  // `"manager"`) is committed with its doer as author and pushed, after the checkout has taken what others pushed. People edit the document both here
@@ -30,7 +32,7 @@
30
32
  // and the push error live in a linked worktree's own git dir) are read from `.git` itself, so a board open starts no
31
33
  // process. A write does start its commit's git processes, beside the loop (A41).
32
34
  import { current } from './call.mjs';
33
- import { execFile, execFileSync, spawn } from 'node:child_process';
35
+ import { execFile, execFileSync as workLedgerNativeExecFileSync, spawn } from 'node:child_process';
34
36
  import { readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
37
  import { basename, dirname, join, relative, resolve } from 'node:path';
36
38
 
@@ -35,12 +35,23 @@ function connection() {
35
35
  pid INTEGER NOT NULL, caller TEXT NOT NULL, purpose TEXT NOT NULL, method TEXT NOT NULL,
36
36
  endpoint TEXT NOT NULL, code TEXT NOT NULL, reason TEXT NOT NULL);
37
37
  CREATE INDEX IF NOT EXISTS github_refusals_window ON github_refusals(started_at,purpose);
38
+ CREATE TABLE IF NOT EXISTS github_source_facts (key TEXT PRIMARY KEY, value TEXT NOT NULL);
38
39
  CREATE TABLE IF NOT EXISTS github_observation_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);`);
39
40
  db.prepare("INSERT OR IGNORE INTO github_observation_meta(key,value) VALUES('refusals_since',?)").run(Date.now());
40
41
  connections.set(path, db);
41
42
  return db;
42
43
  }
43
44
 
45
+ // Source facts have no discovery TTL. Merged heads are immutable; branch watermarks
46
+ // retain complete seeded history and are advanced only after a successful incremental read.
47
+ export function githubSourceFact(identity, value) {
48
+ const key = createHash('sha256').update(identity).digest('hex'), db = connection();
49
+ if (value !== undefined) db.prepare(`INSERT INTO github_source_facts(key,value) VALUES(?,?)
50
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, JSON.stringify(value));
51
+ const row = db.prepare('SELECT value FROM github_source_facts WHERE key=?').get(key);
52
+ return row ? JSON.parse(row.value) : null;
53
+ }
54
+
44
55
  const discoveryReads = new Map();
45
56
  const discoveryPending = () => Object.assign(new Error('GitHub discovery is already being read by another process; no duplicate read was started'), { code: 'github_discovery_pending', deferred: true });
46
57
  /** Advisory observations survive command/owner restarts. Fresh transition reads never consume a cached value,
@@ -60,6 +71,12 @@ export function githubDiscoveryHeld(identity) {
60
71
  return held && held.until_at > Date.now() && held.value != null ? JSON.parse(held.value) : null;
61
72
  }
62
73
 
74
+ /** When the observation kept for `identity` was last read (ms), or 0; no claim, no wire attempt. */
75
+ export function githubDiscoveryObservedAt(identity) {
76
+ const key = createHash('sha256').update(identity).digest('hex');
77
+ return Number(connection().prepare('SELECT observed_at FROM github_discovery WHERE key=?').get(key)?.observed_at ?? 0);
78
+ }
79
+
63
80
  export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlOf = () => ttl, cacheErrors = true } = {}) {
64
81
  const key = createHash('sha256').update(identity).digest('hex');
65
82
  const localKey = `${pathOf()}\0${key}`;
@@ -111,7 +128,7 @@ export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlO
111
128
  }
112
129
  return pending;
113
130
  }
114
- const refusal = (message) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET' });
131
+ const refusal = (message, retry_at) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET', deferred: true, retry_at });
115
132
  const integer = (value) => value != null && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
116
133
  function endpointOf(url) {
117
134
  const safe = new URL(url); safe.username = ''; safe.password = '';
@@ -133,14 +150,19 @@ async function issue(url, options, { purpose }) {
133
150
  db.exec('BEGIN IMMEDIATE');
134
151
  try {
135
152
  const blocked = db.prepare('SELECT until_at,reason FROM github_cooldown WHERE credential=?').get(credential);
136
- if (blocked?.until_at > at) throw refusal(`GitHub REST deferred until ${new Date(blocked.until_at).toISOString()}: ${blocked.reason}`);
137
153
  const count = db.prepare(`SELECT COUNT(*) total, COALESCE(SUM(purpose='discovery'),0) discovery
138
154
  FROM github_attempts WHERE started_at>?`).get(at - HOUR);
139
- if (count.total >= GITHUB_HOURLY_ATTEMPTS || purpose === 'discovery' && count.discovery >= GITHUB_DISCOVERY_ATTEMPTS) {
140
- const oldest = db.prepare(`SELECT MIN(started_at) at FROM github_attempts WHERE started_at>?
141
- AND (?='total' OR purpose='discovery')`).get(at - HOUR, count.total >= GITHUB_HOURLY_ATTEMPTS ? 'total' : 'discovery');
142
- throw refusal(`GitHub ${purpose} deferred by this home's REST budget (${count.total}/${GITHUB_HOURLY_ATTEMPTS} total, ${count.discovery}/${GITHUB_DISCOVERY_ATTEMPTS} discovery in the last hour); retry after ${new Date(oldest.at + HOUR + 1).toISOString()}`);
155
+ // Expire enough attempts to get below *every* applicable limit. MIN is wrong after an
156
+ // over-limit retained window, and the discovery and total counters may free at different times.
157
+ let retryAt = blocked?.until_at > at ? blocked.until_at : 0;
158
+ for (const [kind, used, limit] of [['total', count.total, GITHUB_HOURLY_ATTEMPTS],
159
+ ...(purpose === 'discovery' ? [['discovery', count.discovery, GITHUB_DISCOVERY_ATTEMPTS]] : [])]) {
160
+ if (used < limit) continue;
161
+ const expiry = db.prepare(`SELECT started_at FROM github_attempts WHERE started_at>?
162
+ AND (?='total' OR purpose='discovery') ORDER BY started_at LIMIT 1 OFFSET ?`).get(at - HOUR, kind, used - limit);
163
+ retryAt = Math.max(retryAt, expiry.started_at + HOUR + 1);
143
164
  }
165
+ if (retryAt) throw refusal(`GitHub ${purpose} deferred by this home's REST budget/provider hold (${count.total}/${GITHUB_HOURLY_ATTEMPTS} total, ${count.discovery}/${GITHUB_DISCOVERY_ATTEMPTS} discovery in the last hour); earliest eligibility ${new Date(retryAt).toISOString()}, subject to admission recheck${blocked?.until_at > at ? `: ${blocked.reason}` : ''}`, retryAt);
144
166
  db.prepare('INSERT INTO github_attempts(id,credential,started_at,machine,pid,caller,purpose,method,endpoint) VALUES(?,?,?,?,?,?,?,?,?)')
145
167
  .run(id, credential, at, hostname(), process.pid, caller, purpose, method, endpoint);
146
168
  db.exec('COMMIT');
@@ -169,7 +191,7 @@ async function issue(url, options, { purpose }) {
169
191
  (response?.status === 429 || response?.status === 403) && retry != null ? Date.now() + retry * 1000 : 0,
170
192
  remaining === 0 && reset == null ? Date.now() + 60_000 : 0);
171
193
  if (until > Date.now()) db.prepare(`INSERT INTO github_cooldown(credential,until_at,reason) VALUES(?,?,?)
172
- ON CONFLICT(credential) DO UPDATE SET until_at=MAX(until_at,excluded.until_at),reason=excluded.reason`)
194
+ ON CONFLICT(credential) DO UPDATE SET reason=CASE WHEN excluded.until_at>until_at THEN excluded.reason ELSE reason END,until_at=MAX(until_at,excluded.until_at)`)
173
195
  .run(credential, until, remaining === 0 ? `${resource ?? 'REST'} remaining=0 on ${endpoint}` : `HTTP ${response.status} Retry-After on ${endpoint}`);
174
196
  }
175
197
  }
@@ -1,3 +1,4 @@
1
+ import { latestEvent } from './queries.mjs';
1
2
  // Service supervision retains evidence; only a manager/creator act releases dispatch.
2
3
  import { event, json, now } from './store.mjs';
3
4
  import { detachedCard, detachedSession } from './custody.mjs';
@@ -65,27 +66,14 @@ export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId
65
66
  /** Older ended runs are audited before eligibility: a completed run is not permission to repeat. */
66
67
  export function auditEndedRuns(db, board, entry) {
67
68
  if (recoversRuns(entry)) {
68
- // Retained positive exit receipts from an earlier version qualify the same
69
- // failed-before-live fallback as a newly supervised resume. An unknown
70
- // reading, a mere ended row or a once-live resume does not qualify.
71
- for (const row of db.prepare(`SELECT r.id,r.metadata FROM task_runs r JOIN tasks t ON t.id=r.task_id
72
- WHERE r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id) AND r.ended_at IS NOT NULL
73
- AND t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived')`).all()) {
74
- const metadata = json(row.metadata, {}), session = metadata.supercode;
75
- if (detachedSession(db, session)) continue;
76
- if (session?.mode !== 'resume' || session.seen_at || session.resume_failed_before_live) continue;
77
- const exit = db.prepare("SELECT 1 FROM task_events WHERE run_id=? AND kind='run_incident' AND json_extract(payload,'$.observation')='exited' AND json_extract(payload,'$.evidence.code')=1").get(row.id);
78
- if (!exit) continue;
79
- metadata.supercode = { ...session, resumable: false, resume_failed_before_live: true };
80
- db.prepare('UPDATE task_runs SET metadata=? WHERE id=?').run(JSON.stringify(metadata), row.id);
81
- }
69
+ // A failed native resume preserves its conversation. Historical failures grant no fresh-session permission.
82
70
  // The older organization workflow automatically paused a clean native exit.
83
71
  // Release only that exact platform outcome, never a person's later pause.
84
72
  for (const task of db.prepare(`SELECT t.id,c.paused FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
85
73
  WHERE t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived') AND c.paused IS NOT NULL AND NOT COALESCE(c.archived,0)`).all()) {
86
74
  const pause = json(task.paused, null);
87
75
  if (detachedCard(db, task.id)) continue;
88
- const last = db.prepare("SELECT kind,payload FROM task_events WHERE task_id=? AND kind IN ('closed_by_hand','paused','resumed') ORDER BY id DESC LIMIT 1").get(task.id);
76
+ const last = latestEvent(db, task.id, ['closed_by_hand', 'paused', 'resumed'], 'kind,payload');
89
77
  const ended = json(last?.payload, {});
90
78
  // The machine may have moved since this run. Match the retained board
91
79
  // actor's exact label, never a user's actor or just the reason text.
@@ -101,10 +89,10 @@ export function auditEndedRuns(db, board, entry) {
101
89
  WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status IN ('running','review')`).all()) {
102
90
  const session = json(task.run_metadata, {})?.supercode;
103
91
  if (detachedCard(db, task.id) || detachedSession(db, session)) continue;
104
- if (!session?.monitor_held) continue;
92
+ if (!session?.monitor_held || session.continuation_required) continue;
105
93
  const state = db.prepare('SELECT paused,blocked_by,archived FROM supercode_cards WHERE task_id=?').get(task.id);
106
94
  if (json(state?.paused, null) || json(state?.blocked_by, [])?.length || state?.archived) continue;
107
- const last = db.prepare("SELECT kind,payload FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1").get(task.id);
95
+ const last = latestEvent(db, task.id, ['dispatch_held','dispatch_released','session_adopted'], 'kind,payload');
108
96
  const hold = json(last?.payload, {});
109
97
  if (last?.kind !== 'dispatch_held' || hold.run !== task.run_id || hold.incident !== session.incident) continue;
110
98
  requestSessionClose(db, task.id, { runId: task.run_id });