@volter/supercode-orchestrator 0.5.227 → 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
@@ -32,14 +32,23 @@
32
32
  // which every snapshot frame reads, stays complete. Its supercode_board_state key (publication_trim) is one 04bc6948
33
33
  // never reads. A store `workflow compact` rebuilt is in incremental auto-vacuum, which SQLite opens and writes the
34
34
  // same for any release.
35
+ // 635a0880… (#2533, the work ledger: ztrack.mjs registers its `board.transaction` operation at load and runs tx()
36
+ // inside measureWork, which calls it at once and answers its value unchanged) is read by f5dca0f9…: it adds no
37
+ // table, column, document text or written value; only the module's source moved.
38
+ // a188edd2… (VO-3779, current board selectors and held-read observation) is read by 635a0880…: it adds only indexes
39
+ // to the persistent layout. Changed-row tracking uses connection-local TEMP triggers and bounded in-memory marks;
40
+ // read boundaries and observation revisions write no persistent state. There is no new table, column, document
41
+ // text or JSON shape for the predecessor to parse, and SQLite ignores the added indexes when its queries do not
42
+ // use them. Predecessor readback is qualified against the exact candidate format, not this source-only explanation.
35
43
  // `format` is null when a member READS names occurs more than once (the owner then compares `storage`).
36
44
  import { createHash } from 'node:crypto';
37
45
  import fs, { readFileSync } from 'node:fs';
38
46
  import { createRequire, syncBuiltinESMExports } from 'node:module';
39
47
  import { DatabaseSync } from 'node:sqlite';
40
48
  import { layOut } from './store.mjs';
49
+ import { restingCodecContract } from './resting-protocol.mjs';
41
50
 
42
- const MODULES = ['store.mjs', 'files.mjs', 'ztrack.mjs', 'published.mjs'];
51
+ const MODULES = ['store.mjs', 'files.mjs', 'ztrack.mjs', 'published.mjs', 'resting-state.mjs', 'session-continuity.mjs', 'resting-admission.mjs', 'resting-protocol.mjs', 'workflow.mjs'];
43
52
  // Members that only read a store or choose which rows a load keeps (VO-3578 row 22's #2328 changed these): a change to
44
53
  // one keeps the format. A member joins this list only when what it writes is rows of the laid-out tables, never a file,
45
54
  // a document's text or a JSON shape.
@@ -54,8 +63,19 @@ const require = createRequire(import.meta.url);
54
63
  // What the codec writes for two cards, one open and one ended, each given a number as a numbering codec would show it.
55
64
  function renders() {
56
65
  const codec = require('@volter/ztrack/board-document');
57
- const cards = [{ id: 't_00000000', title: 'format', status: 'todo', body: 'A card.' }, { id: 't_00000001', title: 'ended', status: 'done', body: '' }];
58
- return cards.map((card, i) => codec.renderBoardCard({ ...card, number: i + 1, key: 'KB' }, 2));
66
+ const preset = require('@volter/ztrack/kanban-preset');
67
+ const cards = [{ id: 't_00000000', title: 'format', status: 'todo', body: 'A card.' }, { id: 't_00000001', title: 'ended', status: 'done', body: '' },
68
+ // Optional metadata is format too: changing a deadline/backlog writer must move the digest even when
69
+ // the existing cards on a particular board have not yet acquired either flag.
70
+ { id: 't_00000002', title: 'resting format', status: 'running', body: 'Retained lane.',
71
+ paused: { by: 'manager', reason: 'until later', until: 2000000000, generation: 'format-generation' },
72
+ backlog: { by: 'manager', reason: 'accepted for later', since: 1900000000 } },
73
+ ];
74
+ const render = (card, i, options) => codec.renderBoardCard({ ...card, ...preset.serializeKanbanCard({
75
+ ...card, summary: '', acceptanceCriteria: [], relations: [],
76
+ }, options), number: i + 1, key: 'KB' }, 2, undefined, options);
77
+ return { legacy: cards.slice(0, 2).map((card, i) => render(card, i, { resting: 'legacy' })),
78
+ preserved: cards.map((card, i) => render(card, i, { resting: 'preserve' })) };
59
79
  }
60
80
  // A member's lines in its module: from its first line (` name(` in a class, `function name(` or `const name =` at the
61
81
  // top) to the line that closes it at the same indent, or that line alone when it ends the member. None when absent.
@@ -103,21 +123,123 @@ function format() {
103
123
  const layout = db.prepare("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name, tbl_name").all()
104
124
  .map(({ type, name, tbl_name: table, sql }) => [type, name, table, sql]);
105
125
  const modules = MODULES.map((file) => [file, code((READS[file] ?? []).reduce(without, source(file)))]);
106
- return createHash('sha256').update(JSON.stringify({ layout, modules, renders: renders() })).digest('hex');
126
+ return createHash('sha256').update(JSON.stringify({ layout, modules, codec: restingCodecContract(), renders: renders() })).digest('hex');
107
127
  } finally { db.close(); }
108
128
  }
109
129
 
110
- const READABLE_BY = {
130
+ const LEGACY_READABLE_BY = {
111
131
  '2bfde37add33506a56e910c49c9643e91a4f96d36378daeb8cdae3018835a089': ['c23c3ca4bd184cd9c6d9312f4c3fd0ab7f8bc5bde4926f01d32951a641ebde7d'],
112
132
  '2964acd14bca601e85ffaccc160bf058ee327f4aafb9ee5a0d8559fe509632e1': ['2bfde37add33506a56e910c49c9643e91a4f96d36378daeb8cdae3018835a089'],
113
133
  '04bc6948582c8de470a7c5301ede239bcf26730f7a1d725ccde7fb03aecf9ba5': ['2964acd14bca601e85ffaccc160bf058ee327f4aafb9ee5a0d8559fe509632e1'],
114
134
  'f5dca0f9a1337a7f807b8a21c39826bfd5c165bcb6a3d847fb9fb31a9d620afb': ['04bc6948582c8de470a7c5301ede239bcf26730f7a1d725ccde7fb03aecf9ba5'],
135
+ '635a08803519d72e943488171abafd1f5387b4c76244b129f9c485bb45bf95fc': ['f5dca0f9a1337a7f807b8a21c39826bfd5c165bcb6a3d847fb9fb31a9d620afb'],
136
+ 'a188edd20da4124c2b27a10847f0cd1072bb1e0bfcbf887d7aef160289e13bb7': ['635a08803519d72e943488171abafd1f5387b4c76244b129f9c485bb45bf95fc'],
137
+ // VO-3783: actual 771cd155 / codec be866b90 reads and rewrites this exact bridge's legacy output across all
138
+ // backings; fresh document-only native rebuild preserves root/child identities, numbers, body and pause actor.
139
+ 'a61920d6b543cfc9c5a68b8c670ab66ab0d9427d11dd03bf078ac0fc1c8c160b': ['635a08803519d72e943488171abafd1f5387b4c76244b129f9c485bb45bf95fc'],
140
+ // Existing stored bodies retain the same frozen predecessor projection; newly supplied bodies remain guarded.
141
+ 'e126e6e0b60f354420a77364cd337d0774fc1dc5f80e0686919f684185e2c6be': ['635a08803519d72e943488171abafd1f5387b4c76244b129f9c485bb45bf95fc'],
142
+ // The corrected inline-HTML codec retains literal outcome evidence under the same frozen predecessor grammar.
143
+ 'db6050ed7b48b9697d393ae880d1c61db0057c8c116fa9822bbdd37ff2a065e2': ['635a08803519d72e943488171abafd1f5387b4c76244b129f9c485bb45bf95fc'],
144
+ // VO-3783 preparation reuses only successful exact-text admission within one captured generation.
145
+ // Its bridge writes the same legacy data; the actual 635a predecessor qualifies the retained rollback.
146
+ 'e203b32c46dd55e7c09a71fd429200058b4a2174e8eedaa6a7720123df1eb0de': ['635a08803519d72e943488171abafd1f5387b4c76244b129f9c485bb45bf95fc'],
147
+ };
148
+
149
+ // Each relationship is independently qualified against its exact candidate. A bridge running over a modern
150
+ // predecessor may preserve modern fields and must never advertise that output to a legacy rollback.
151
+ const READABLE_BY = {
152
+ // Complete-reader admission only: both native releases bind corrected codec 4a1f551. The final reader preserves
153
+ // ztrack root/nested records, rich Acceptance/escapes, exact bodies, holds, sessions and publication frames.
154
+ 'a61920d6b543cfc9c5a68b8c670ab66ab0d9427d11dd03bf078ac0fc1c8c160b': ['9d9b9f4e2d28e3b4fad4381501155a1e8c9f52b9fdd7b20a9de062a503446289'],
155
+ // The complete bridge reader preserves this final writer's modern root/nested records and existing holds.
156
+ // Its legacy-only stored-body refusals do not change complete-reader preservation; retained handover qualifies both.
157
+ '9d9b9f4e2d28e3b4fad4381501155a1e8c9f52b9fdd7b20a9de062a503446289': ['a61920d6b543cfc9c5a68b8c670ab66ab0d9427d11dd03bf078ac0fc1c8c160b'],
158
+ // Codec 8133a03 keeps literal inline HTML in outcome evidence. Older modern readers lose that outcome,
159
+ // so this corrected pair advertises only its matching complete reader, never the older modern formats.
160
+ 'db6050ed7b48b9697d393ae880d1c61db0057c8c116fa9822bbdd37ff2a065e2': ['d2bec3f8a1c708aed6424ea280afd5ce024cf1dcc0c74bc03c9ca2b5821d9f6c'],
161
+ 'd2bec3f8a1c708aed6424ea280afd5ce024cf1dcc0c74bc03c9ca2b5821d9f6c': ['db6050ed7b48b9697d393ae880d1c61db0057c8c116fa9822bbdd37ff2a065e2'],
162
+ // The exact preparation-cache pair preserves the same corrected codec, admission and stored values.
163
+ // Both directions require the complete-reader capability and retained native handover/readback.
164
+ 'e203b32c46dd55e7c09a71fd429200058b4a2174e8eedaa6a7720123df1eb0de': ['df98f5ddd847054e7324f4b06ef27b22b16a50afef938fca691880778c1db188'],
165
+ 'df98f5ddd847054e7324f4b06ef27b22b16a50afef938fca691880778c1db188': ['e203b32c46dd55e7c09a71fd429200058b4a2174e8eedaa6a7720123df1eb0de', '391fcc8198003c5c0b10a8491c0c5a35a72263250d2bf7de46d06e0cb749d23e'],
166
+ // #2659 only changes the harness a replace_session authorization names inside the existing run metadata
167
+ // (replacement_authorization.target.harness); no table, column, document text or JSON shape moves, and either reader
168
+ // compares that value with its own lane harness exactly as before.
169
+ '391fcc8198003c5c0b10a8491c0c5a35a72263250d2bf7de46d06e0cb749d23e': ['df98f5ddd847054e7324f4b06ef27b22b16a50afef938fca691880778c1db188'],
170
+ // VO-3795 only drops a card's session reading's time (`execution.observed_at`, `fresh_until`) from the snapshots it
171
+ // stores in supercode_publications, and reads a presence reading's currency in place of its time in a head's
172
+ // fingerprint. No table, column, document text or other JSON field moves. 391fcc81 reads a stored snapshot as any JSON
173
+ // card (`workflow events` yields it, its connector's publishableCard sends it on) and reads neither field; a head this
174
+ // writer compared carries a fingerprint 391fcc81 never matches, so it compares that card's snapshot once again.
175
+ '46a45d5c68b9ff7e8cb9638b8e381d849fcd5c5130aa75789b207fa293c36cca': ['391fcc8198003c5c0b10a8491c0c5a35a72263250d2bf7de46d06e0cb749d23e'],
176
+ // VO-3795 freshness correction: the fingerprint and snapshot share one clock reading. Stored tables, document
177
+ // bytes and snapshot fields are unchanged; the predecessor reads the same execution states and fingerprint text.
178
+ '677974041866e5d762207ad9a69c45004614c5b156f8b787e3d221b27de6dcbd': ['46a45d5c68b9ff7e8cb9638b8e381d849fcd5c5130aa75789b207fa293c36cca'],
179
+ // VO-3801 / ADR 0080: the no-refusals workflow keeps the existing layout, codec and document fields. Its optional
180
+ // raw review request fields and retained request/hold event payloads are ordinary task_events JSON, which the actual
181
+ // selected MacBook predecessor preserves and reads by named kind. Its hold/run readers preserve wait
182
+ // deadlines, stopped-session receipts and prior dispatch holds. Rollback retains queued requests; only the new
183
+ // dispatcher consumes them, so the claim promises readable retained state, never execution by an older dispatcher.
184
+ // The exact candidate/codec and predecessor require native handover and rollback readback before adoption.
185
+ 'e3883cbe12612694af3f3aec332134a583aad26e527bf89ab209165cb6b4c429': ['677974041866e5d762207ad9a69c45004614c5b156f8b787e3d221b27de6dcbd'],
186
+ // VO-3810 only narrows Acceptance edit admission and names the guard that refused an event. Existing rows,
187
+ // layout, document bytes and codec stay unchanged; accepted edits still store the same body/title/id fields,
188
+ // and refusal reasons remain text in the existing event payload. The exact serving e3883cbe reader must
189
+ // preserve the candidate's retained state through native handover and rollback before live adoption.
190
+ '549c19dabb29be516af7ad3277eb24d20405ddcd27937c4a0e67e3df4b7b7cb8': ['e3883cbe12612694af3f3aec332134a583aad26e527bf89ab209165cb6b4c429'],
191
+ // #2741 makes an ended card use its existing done/archived stored_as value even when a hold remains.
192
+ // It changes no layout, codec, document field, event payload or status vocabulary. The exact serving
193
+ // 549c19da reader already reads those terminal values and retains the workflow stage and hold events;
194
+ // its older projection may reproduce the fixed display bug, but loses no state needed by either reader.
195
+ // This exact pair permits retained handover; it does not grant a later writer the same admission.
196
+ '3c3a7beb442f5d29d6b494c9244b551e2dceb881d19dc19ce54f512393ad9279': ['549c19dabb29be516af7ad3277eb24d20405ddcd27937c4a0e67e3df4b7b7cb8'],
197
+ // #2751 writes null as a replacement authorization's target.agent (task_runs metadata) when the session's agent
198
+ // ended here. No table, column, codec, document field or event kind moves, and the field was already null for a
199
+ // session with no agent. The exact serving 3c3a7beb reader reads that null and falls back to the prior session's
200
+ // agent (dispatch.mjs existingAgent), so a rollback may repeat the fixed revival but loses no stored state.
201
+ // This exact pair permits retained handover; it does not grant a later writer the same admission.
202
+ 'efc27e5963c668603834be6b9d5d31b976802bc8b1c43b7a924d89db782db106': ['3c3a7beb442f5d29d6b494c9244b551e2dceb881d19dc19ce54f512393ad9279'],
203
+ // #2759 queues a closed card's worktree retirement as a `retire_workspace` row in supercode_effects (workflow.mjs
204
+ // queueCleanup, one row per card; `scratch: true` where a workflow declares cleanup_workspace), and records worktree_kept / worktree_removed task_events and a board comment. No table, column,
205
+ // codec or document field moves. The exact serving efc27e59 reader (d818a7d607) knows only `cleanup_workspace`, which
206
+ // it answers with `git worktree remove --force` (engine.mjs:892 -> 487); a `retire_workspace` row reaches its
207
+ // runEffect's closing `return true` (engine.mjs:905) and is deleted unread (engine.mjs:865), so a rollback leaves the
208
+ // worktree. It reads task_events by named kinds (an unnamed last kind publishes as task.changed, published.mjs:354),
209
+ // writes the same board comment itself (engine.mjs:1413), and its gc drops the two new kinds past retention
210
+ // (engine.mjs:1664), after which this release's gc no longer rereads that closed card's kept worktree.
211
+ // This exact pair permits retained handover; it does not grant a later writer the same admission.
212
+ '7ca68e5be53314541223fbc0d04160e1b1f55ab033bb84581de827d63e63ab8d': ['efc27e5963c668603834be6b9d5d31b976802bc8b1c43b7a924d89db782db106'],
213
+ // Current-run caller ownership changes only a SELECT. Layout, codec, document bytes and stored JSON are
214
+ // unchanged; the 7ca68e5b reader preserves every stored value, including the ended runs excluded by this query.
215
+ '572c21b048d39451954181c5fa808beacac2cb70d09b836881fd1a55b0b04902': ['7ca68e5be53314541223fbc0d04160e1b1f55ab033bb84581de827d63e63ab8d'],
216
+ // First-reader retry checks and lazy scope reads preserve existing board/run/document values and codec.
217
+ // The actual serving b1f328ed reader retains them; ADR0080 still requires exact native rollback qualification.
218
+ '1fe57063f9d050c5a0935c0fc160416b5d7cf7e96430952e7ce6fc15bf00a4fc': ['b1f328ed24cd2bceaf881b2928b0ba10d243caddf546d6d50c5e52d413a126fc'],
219
+ // Indexed hold reads and demand-selected notice gates preserve layout, codec, document values and retained JSON.
220
+ // The serving b588e9c0 reader preserves those values; exact native rollback qualification remains required.
221
+ '07748d0b8f684fad5bd774f7c792b6a98ece77d4b6a1c9a125e2a2d452507946': ['b588e9c0a6175bd250ded6d469b7f4f278c159ea8787599f40d30b622e496088'],
222
+ // One reviewer per arc (ADR 0003 c84, ADR 0019): every run is its card's current run, on its card's machine. The
223
+ // layout drops two indexes and adds none; codec and document bytes are unchanged. Its new task_events kinds
224
+ // (review_queue_waiting, first_input_refiled), a run's owed brief (brief_owed) and a `closed` or `refused` queue settlement are
225
+ // ordinary JSON the serving 0d5b21c8
226
+ // reader (4d2208fe) reads by named kinds and counts as settled, and its one-at-a-time runs are a subset of the
227
+ // executions that reader reads, so rollback needs no bridge. Its worker subscriptions name no execution owner; that
228
+ // reader's subscription repair rebinds them on its first round. Exact native handover and rollback readback remain
229
+ // required before adoption.
230
+ 'f4c5fc89e326027b2e876a04cf665184023e186247e1c8670c28740f25131a58': ['0d5b21c8f5a22795053a2d0754f8eadbbd432b1a0422f110bd6064ede1f4cb68'],
231
+ // ADR 0262 adds JSON-safe allocation indexes; no table, column, codec or document bytes change. Reviewer
232
+ // cwd/allocation fields are optional run metadata JSON; retirement paths and stage_entered are event payloads.
233
+ // The serving 1429cad5 reader preserves this JSON and reads events by named kind. Rollback can reproduce
234
+ // its prior workspace behavior without losing retained state. This claim admits only this exact writer.
235
+ '7b49bef1dfedbdefb4b235451ee95153f0b7482fbfaca2277354e0f4f4bdadf5': ['f4c5fc89e326027b2e876a04cf665184023e186247e1c8670c28740f25131a58'],
115
236
  };
116
237
 
117
- export function storageContract() {
238
+ export function storageContract({ restingAdmission = 'unknown' } = {}) {
118
239
  const storage = createHash('sha256');
119
240
  for (const file of MODULES) storage.update(file).update(readFileSync(new URL(file, import.meta.url)));
120
241
  let formatDigest = null;
121
242
  try { formatDigest = format(); } catch (error) { process.stderr.write(`board storage contract: no format digest (${error.message}); the owner compares storage\n`); }
122
- return { storage: storage.digest('hex'), format: formatDigest, readableBy: (formatDigest && READABLE_BY[formatDigest]) || [] };
243
+ const qualified = restingAdmission === 'legacy-reader' ? LEGACY_READABLE_BY : restingAdmission === 'compatible-reader' ? READABLE_BY : {};
244
+ return { storage: storage.digest('hex'), format: formatDigest, readableBy: (formatDigest && qualified[formatDigest]) || [] };
123
245
  }
package/board/store.mjs CHANGED
@@ -20,9 +20,11 @@ import { hostname } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { DatabaseSync } from 'node:sqlite';
22
22
  import { openZtrackBoard } from './ztrack.mjs';
23
- import { byOwner, LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
23
+ import { byOwner, LIST_CONNECTION, readBoundary, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
24
+ import { trackCurrentRecords } from './current-records.mjs';
24
25
  import { isFilesBoard, openFilesBoard } from './files.mjs';
25
26
  import { signalBoardWrite } from './wake.mjs';
27
+ import { NEW_HOLD_SQL, PAUSE_DEADLINE } from './resting-state.mjs';
26
28
  import { PUBLICATION_COLUMNS, PUBLICATION_SCHEMA, incrementalStore, pruneDue, prunePublications, publicationTriggers, reclaimDue, reclaimSlice, recordCardPublications, trimDue, trimPublications } from './published.mjs';
27
29
 
28
30
  import { fillReadableIds } from './readable-id.mjs';
@@ -94,7 +96,7 @@ CREATE TABLE IF NOT EXISTS supercode_session_operations (
94
96
  // An effect's attempts: how many were made, when the next may be, and, once its attempts are spent, when it failed and
95
97
  // why (engine.mjs runEffects). A failed effect is kept as the record of its terminal state and never tried again.
96
98
  const EFFECT_COLUMNS = { attempts: 'INTEGER NOT NULL DEFAULT 0', next_at: 'INTEGER NOT NULL DEFAULT 0', failed_at: 'INTEGER', last_error: 'TEXT' };
97
- const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', detached: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', requested_by_unverified: 'TEXT' };
99
+ const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', backlog: 'TEXT', detached: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', requested_by_unverified: 'TEXT' };
98
100
 
99
101
  // ------------------------------------------------------------------ backings
100
102
  //
@@ -233,13 +235,13 @@ const NOTICE_ADDRESS = `CASE WHEN json_valid(metadata,6) THEN ${RUN_ADDRESS} END
233
235
  export const NOTICE_RUN_IDS = `SELECT id FROM task_runs WHERE ${NOTICE_SESSION}=? UNION SELECT id FROM task_runs WHERE ${NOTICE_ADDRESS}=?`;
234
236
  const RUN_LAUNCH_PENDING = "json_extract(metadata, '$.supercode.launch_pending')";
235
237
  export const CALLER_RUN_ADDRESS = `SELECT ${RUN_ADDRESS} AS address FROM task_runs WHERE ${RUN_SESSION} = ? ORDER BY id DESC LIMIT 1`;
236
- // Launch/adoption commits the native address and its immutable role-agent together.
237
- // Pending/unissued launches are not an ownership receipt. Read only that identity,
238
- // never the current assignee, a profile hint, or mutable manager/admission state.
238
+ // The current launch/adoption commits the native address and its immutable role-agent together.
239
+ // Ended/superseded runs and pending/unissued launches cannot establish the caller's current ownership.
239
240
  export const CALLER_RUN_AGENT = `SELECT DISTINCT
240
241
  json_extract(metadata, '$.supercode.agent_id') AS agent_id,
241
242
  json_extract(metadata, '$.supercode.agent_org') AS org
242
- FROM task_runs WHERE ${RUN_SESSION} = ? AND ${RUN_ADDRESS} = ?
243
+ FROM task_runs r JOIN tasks t ON t.id = r.task_id AND t.current_run_id = r.id
244
+ WHERE r.ended_at IS NULL AND ${RUN_SESSION} = ? AND ${RUN_ADDRESS} = ?
243
245
  AND COALESCE(${RUN_LAUNCH_PENDING}, 0) = 0
244
246
  AND COALESCE(json_extract(metadata, '$.supercode.launch_deferred'), 0) = 0
245
247
  AND json_extract(metadata, '$.supercode.mode') IN ('fresh', 'resume', 'adopt')
@@ -247,6 +249,17 @@ export const CALLER_RUN_AGENT = `SELECT DISTINCT
247
249
  AND json_extract(metadata, '$.supercode.agent_org') IS NOT NULL`;
248
250
  const RUN_SESSION_INDEX = 'idx_runs_supercode_session';
249
251
  const RUN_LAUNCH_PENDING_INDEX = 'idx_runs_supercode_launch_pending';
252
+ // Strict JSON matches the JS metadata reader, including on old stores containing malformed text.
253
+ export const runField = (name) => `json_extract(CASE WHEN json_valid(metadata) THEN metadata END, '$.supercode.${name}')`;
254
+ export const RUN_SLOT = `COALESCE(${runField('slot')},'worker')`;
255
+ export const REVIEW_ALLOCATION_PATH = runField('review_workspace.path');
256
+ export const REVIEW_ALLOCATION_MACHINE = runField('review_workspace.machine');
257
+ export const REVIEW_ALLOCATION_KEY = `CASE WHEN ${REVIEW_ALLOCATION_PATH} IS NOT NULL THEN json_array(${REVIEW_ALLOCATION_PATH},${REVIEW_ALLOCATION_MACHINE},${RUN_SLOT}) END`;
258
+ export const RUN_AGENT_REF = `COALESCE(${runField('address')},CASE WHEN ${runField('session_id')} IS NOT NULL AND ${runField('session_id')} NOT IN ('',0)
259
+ AND ${runField('machine')} IS NOT NULL AND ${runField('machine')} NOT IN ('',0) THEN 'sc:'||${runField('machine')}||':'||COALESCE(${runField('harness')},'claude-code')||':'||${runField('session_id')} END,'run:'||id)`;
260
+ export const CLOSE_OPEN_RUNS = `${runField('close_confirmed_at')} IS NULL AND ${runField('close_transferred_to')} IS NULL
261
+ AND (ended_at IS NOT NULL OR ${runField('close_requested')} IS NOT NULL)
262
+ AND (${runField('pane')} IS NOT NULL OR ${runField('surface')}='native' OR (${runField('surface')}='headless' AND ${runField('pid')} IS NOT NULL) OR (${runField('launch_key')} IS NOT NULL AND ${runField('launch_pending')}=1))`;
250
263
  // The indexes on a run's metadata. The second holds only the runs whose launch is pending (7 of 1,387 on the store
251
264
  // these were measured for), in the runs' own order.
252
265
  const RUN_INDEXES = [
@@ -256,6 +269,14 @@ const RUN_INDEXES = [
256
269
  ['idx_runs_notice_address', `(${NOTICE_ADDRESS}, id)`],
257
270
  ['idx_runs_open_machine_session', `(json_extract(metadata, '$.supercode.machine'), ${RUN_SESSION}, id) WHERE ended_at IS NULL`],
258
271
  ['idx_runs_open_machine_pane', `(json_extract(metadata, '$.supercode.machine'), json_extract(metadata, '$.supercode.pane'), id) WHERE ended_at IS NULL`],
272
+ ['idx_runs_close_open', `(id) WHERE ${CLOSE_OPEN_RUNS}`],
273
+ ['idx_runs_latest_slot', `(task_id,${RUN_SLOT},id DESC) WHERE ${runField('session_id')} IS NOT NULL AND ${runField('session_id')} NOT IN ('',0)`],
274
+ ['idx_runs_agent_current', `(${RUN_AGENT_REF},id DESC) WHERE ${runField('agent_id')} IS NOT NULL`],
275
+ ['idx_runs_task_agent_ref', `(task_id,${RUN_AGENT_REF},id) WHERE ${runField('agent_id')} IS NOT NULL`],
276
+ ['idx_runs_transfer_session', `(task_id,${runField('machine')},${runField('session_id')},id)`],
277
+ ['idx_runs_transfer_pane', `(task_id,${runField('machine')},${runField('pane')},id)`],
278
+ ['idx_runs_review_allocations', `(task_id,${REVIEW_ALLOCATION_KEY},id DESC) WHERE ${REVIEW_ALLOCATION_KEY} IS NOT NULL`],
279
+ ['idx_runs_review_owners', `(${REVIEW_ALLOCATION_PATH},${REVIEW_ALLOCATION_MACHINE},task_id) WHERE ${REVIEW_ALLOCATION_PATH} IS NOT NULL`],
259
280
  ];
260
281
 
261
282
  // What every verb a session runs asks about its caller (cli.mjs ownCards, dispatch.mjs reconcileCallerLaunch). Each
@@ -430,6 +451,8 @@ export function layOut(db, { fresh }) {
430
451
  addColumns(db, table, added);
431
452
  }
432
453
  addColumns(db, 'supercode_cards', OURS_COLUMNS);
454
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_cards_pause_deadline ON supercode_cards(${PAUSE_DEADLINE},task_id) WHERE paused IS NOT NULL`);
455
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_cards_modern_holds ON supercode_cards(task_id) WHERE ${NEW_HOLD_SQL}`);
433
456
  db.exec('CREATE INDEX IF NOT EXISTS supercode_readable_ids ON tasks(readable_id)');
434
457
  fillReadableIds(db);
435
458
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS supercode_open_readable_ids ON tasks(readable_id) WHERE status NOT IN ('done','cancelled','archived')");
@@ -439,9 +462,15 @@ export function layOut(db, { fresh }) {
439
462
  db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
440
463
  // Current-run joins and unended-run reconciliation must never search all cards/runs for each historical run.
441
464
  db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_current_run ON tasks(current_run_id) WHERE current_run_id IS NOT NULL');
465
+ db.exec("CREATE INDEX IF NOT EXISTS idx_cards_start_candidates ON supercode_cards(task_id) WHERE subtask_of IS NULL AND stage NOT IN ('done','cancelled')");
466
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_start_fallback ON tasks(id) WHERE current_run_id IS NULL AND status NOT IN ('archived','done','cancelled')");
442
467
  db.exec('CREATE INDEX IF NOT EXISTS idx_runs_unended ON task_runs(id) WHERE ended_at IS NULL');
443
468
  // Ordinary comments vastly outnumber explicit source declarations. Keep their JSON discriminator in a small index.
444
469
  db.exec("CREATE INDEX IF NOT EXISTS idx_events_arc_sources ON task_events(task_id,id) WHERE kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array'");
470
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_board_messages ON task_events(id) WHERE kind='board_message'");
471
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_board_message_batches ON task_events(id) WHERE kind='board_message_batch'");
472
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_mail_changes ON task_events(id) WHERE kind IN ('board_message','board_message_sent','board_message_passed','board_message_batch')");
473
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_mail_settled ON task_events(task_id,json_extract(CASE WHEN json_valid(payload) THEN payload END,'$.event')) WHERE kind IN ('board_message_sent','board_message_passed')");
445
474
  // Capacity pages need these columns without visiting task body/result overflow pages.
446
475
  db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_capacity ON tasks(id,status,assignee,current_run_id,model_override)');
447
476
  layOutNumbers(db);
@@ -475,6 +504,7 @@ export function layOut(db, { fresh }) {
475
504
  addColumns(db, 'supercode_effects', EFFECT_COLUMNS);
476
505
  // what changed is marked by the store itself, whoever writes it (published.mjs)
477
506
  publicationTriggers(db);
507
+ trackCurrentRecords(db);
478
508
  }
479
509
 
480
510
  /**
@@ -550,7 +580,10 @@ export function heldConfigWatched(slug, source, watched) {
550
580
  export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
551
581
  // a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
552
582
  if (current().readOnly) return openBoardSnapshot(root, slug);
553
- if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
583
+ // A card create is a write on this held store too. Opening a second files
584
+ // projection here would leave the response reading the old one until its watch
585
+ // fires, even though the new card has already committed on disk.
586
+ if (holding !== null && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
554
587
  let entry = heldBoards.get(slug);
555
588
  if (!entry) {
556
589
  const began = performance.now();
@@ -573,7 +606,7 @@ function heldHandle(entry, slug) {
573
606
  const guard = () => { if (entry.retired) throw Object.assign(new Error(`board ${slug}'s configuration changed while this call held it; nothing was written (read it again)`), { code: 'board_retired' }); };
574
607
  const bound = (target, key) => { const value = target[key]; return typeof value === 'function' ? value.bind(target) : value; };
575
608
  const statement = (st) => new Proxy(st, { get: (target, key) => key === 'run' ? (...args) => { guard(); return target.run(...args); } : bound(target, key) });
576
- const db = new Proxy(entry.board.db, { get: (target, key) => key === LIST_CONNECTION ? target : key === 'prepare' ? (sql) => statement(target.prepare(sql)) : key === 'exec' ? (sql) => { guard(); return target.exec(sql); } : bound(target, key) });
609
+ const db = new Proxy(entry.board.db, { get: (target, key) => key === LIST_CONNECTION ? (target[LIST_CONNECTION] ?? target) : key === 'prepare' ? (sql) => statement(target.prepare(sql)) : key === 'exec' ? (sql) => { guard(); return target.exec(sql); } : bound(target, key) });
577
610
  let closed = false;
578
611
  // a rebuild runs on the backing's own connection, outside its write wrapper (ztrack.mjs vacuum); a plain store's is its db
579
612
  const vacuum = (sql) => { guard(); return typeof entry.board.vacuum === 'function' ? entry.board.vacuum(sql) : entry.board.db.exec(sql); };
@@ -649,14 +682,43 @@ function openBoardNow(root, slug, { create = false, backing = null, document = n
649
682
  * Run `fn(db)` in one write transaction of the board's backing: answers what `fn` answers; rolls back on a throw.
650
683
  * A read inside the transaction is the state it writes over.
651
684
  */
685
+ const totalChanges = db => db.prepare('SELECT total_changes() AS n').get().n;
686
+ // A held SQLite board's `PRAGMA data_version` as this process last saw it, and whether another connection committed
687
+ // before one of its own transactions: SQLite moves the value only for another connection's commit, never its own.
688
+ const dataVersions = new Map(); // slug → { seen, external }
689
+ const dataVersion = db => Number(db.prepare('PRAGMA data_version').get().data_version);
690
+ function noteDataVersion(board, db) {
691
+ if (board.backing !== 'sqlite' || holding === null || !heldBoards.has(board.slug)) return;
692
+ const now = dataVersion(db), state = dataVersions.get(board.slug);
693
+ dataVersions.set(board.slug, { seen: now, external: Boolean(state?.external || (state && state.seen !== now)) });
694
+ }
695
+ /** Whether a watched change of held board `slug`'s store or document (`key`, `path`) is this process's own commit, by
696
+ * what was written: no other connection committed since its own last transaction (data_version), or the document
697
+ * holds the text the board published itself. Anything else, or anything unknown, is another writer's (ADR 0003 c40). */
698
+ export function ownBoardWrite(root, slug, key, path) {
699
+ if (holding === null || realRoot(root) !== holding) return false;
700
+ const board = heldBoards.get(slug)?.board;
701
+ if (!board) return false;
702
+ if (key === 'board-storage') {
703
+ const state = dataVersions.get(slug);
704
+ if (board.backing !== 'sqlite' || !state) return false;
705
+ const now = dataVersion(board.db);
706
+ if (now === state.seen && !state.external) return true;
707
+ dataVersions.set(slug, { seen: now, external: false });
708
+ return false;
709
+ }
710
+ return key.startsWith('document:') ? Boolean(board.ownDocument?.(path)) : false;
711
+ }
652
712
  export const tx = (board, fn) => {
653
713
  // Each transaction is a step named by the function that asked for it, so a round's slow-log line says which of its
654
714
  // steps make its transactions (about a hundred a round, at 150 ms each, on 2026-10-02).
655
715
  const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
656
716
  const started = performance.now();
657
717
  try {
658
- const result = board.tx(db => {const result=fn(db);fillReadableIds(db);dropTaskNumbers(db, board);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root,board.key);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
659
- signalBoardWrite(board.root, actorOf());
718
+ // A transaction whose own work changed nothing signals nothing; the dispatcher's own work signals nothing (wake.mjs).
719
+ let changed = false;
720
+ const result = board.tx(db => {noteDataVersion(board, db);const before=totalChanges(db);const result=fn(db);changed=totalChanges(db)!==before;fillReadableIds(db);dropTaskNumbers(db, board);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root,board.key);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
721
+ if (changed) signalBoardWrite(board.root, actorOf(), { own: current().kind === 'own' });
660
722
  return result;
661
723
  } finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
662
724
  };
@@ -718,7 +780,7 @@ export function retryContention(fn) {
718
780
  }
719
781
  // A read that meets another process's write is retried as a write is (sqliteTx): a dispatcher service and a CLI share
720
782
  // the board, and a lost read must not become a launch failure. Inside a transaction its retry is the transaction's.
721
- export const view = (board, fn) => inTx.has(board.db) ? fn(board.db) : retryContention(() => fn(board.db));
783
+ export const view = (board, fn) => readBoundary(board.db, () => inTx.has(board.db) ? fn(board.db) : retryContention(() => fn(board.db)));
722
784
 
723
785
  // A deferred native transaction, with the whole operation retried on an optimistic conflict.
724
786
  // There is no separate application writer lock or contention timeout. A write inside one is part of it.
package/board/tools.mjs CHANGED
@@ -10,6 +10,7 @@ import * as engine from './engine.mjs';
10
10
  import { assertSourceReceipt } from './ci.mjs';
11
11
  import { completionOwnerOf, isLatestReviewer, loadWorkflow } from './workflow.mjs';
12
12
  import { attachmentRecord, readAttachment } from './attachment-read.mjs';
13
+ import { current, requestReceiptEnvelope } from './call.mjs';
13
14
 
14
15
  const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
15
16
  const error = (message) => JSON.stringify({ error: message });
@@ -28,15 +29,44 @@ export function redact(value) {
28
29
  }
29
30
 
30
31
  const LIFECYCLE = new Set(['kanban_complete', 'kanban_block', 'kanban_request_state', 'kanban_request_review', 'kanban_request_changes', 'kanban_heartbeat', 'kanban_attach', 'kanban_attach_url', 'kanban_unblock']);
31
- const ORCHESTRATOR_ONLY = new Set(['kanban_list', 'kanban_unblock']);
32
32
 
33
33
  /** One board tool call. `root` is the home; `env` the worker's task environment (`HERMES_KANBAN_*`). */
34
34
  export async function boardTool(root, name, args = {}, env = {}) {
35
+ const firstRequest = current().routedRequests?.length ?? 0;
36
+ const firstAccepted = current().acceptedRequests?.length ?? 0;
37
+ const firstQueued = current().queuedRequests?.length ?? 0;
38
+ const firstUnqueued = current().unqueuedReviews?.length ?? 0;
39
+ const answer = await boardToolBody(root, name, args, env);
40
+ const result = JSON.parse(answer);
41
+ if (result.error) {
42
+ // Tool scoping routes reserved acts; missing detail is recorded locally as
43
+ // CLI admission does. A stale worker never acquires a current worker's role.
44
+ try {
45
+ await loadWorkflow(root);
46
+ const board = openBoard(root, args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD);
47
+ try {
48
+ const run = env.HERMES_KANBAN_RUN_ID && json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(Number(env.HERMES_KANBAN_RUN_ID))?.metadata, {})?.supercode;
49
+ setActor(run?.address ?? actorOf() ?? `worker:${env.HERMES_PROFILE || 'unknown'}`);
50
+ const target = args.task_id || args.task_ids?.[0] || env.HERMES_KANBAN_TASK;
51
+ const id = target && engine.getTask(board.db, target) ? target : null;
52
+ const ownerRule = result.ownerRule || /orchestrator's|scoped to task|no longer its current run|only .*manager|person.*completion/i.test(result.error);
53
+ await (ownerRule ? engine.routeRequest : engine.acceptRequest)(board, { id, name, payload: args, reason: result.error });
54
+ } finally { board.close(); }
55
+ } catch (e) { return error(`${result.error}; board request could not be retained: ${e.message}`); }
56
+ }
57
+ const requests = current().routedRequests?.splice(firstRequest) ?? [];
58
+ const accepted = current().acceptedRequests?.splice(firstAccepted) ?? [];
59
+ const queued = current().queuedRequests?.splice(firstQueued) ?? [];
60
+ const unqueued = current().unqueuedReviews?.splice(firstUnqueued) ?? [];
61
+ const receipts = requestReceiptEnvelope({ requests, accepted, queued, unqueued });
62
+ return receipts ? JSON.stringify(receipts) : answer;
63
+ }
64
+
65
+ async function boardToolBody(root, name, args = {}, env = {}) {
35
66
  try { assertSourceReceipt(args); } catch (e) { return error(e.message); }
36
67
  const own = env.HERMES_KANBAN_TASK || null;
37
68
  const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
38
69
  const author = env.HERMES_PROFILE || 'worker';
39
- if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's; read kanban_show for your task's declared doors, or use kanban_request_state, kanban_heartbeat or kanban_comment`);
40
70
  if (args.task_ids !== undefined && (!Array.isArray(args.task_ids) || !args.task_ids.length || args.task_ids.some(id => typeof id !== 'string' || !id.trim()))) return error('task_ids is a nonempty array of card ids.');
41
71
  const tid = args.task_id || args.task_ids?.[0] || own;
42
72
  if (!['kanban_list', 'kanban_create', 'kanban_link'].includes(name) && !tid) return error('task_id is required (or HERMES_KANBAN_TASK in the environment)');
@@ -72,7 +102,17 @@ export async function boardTool(root, name, args = {}, env = {}) {
72
102
  const ownRun = () => {
73
103
  if (!own) return null;
74
104
  const t = engine.getTask(board.db, own);
75
- const r = runId && board.db.prepare('SELECT task_id, ended_at FROM task_runs WHERE id=?').get(runId);
105
+ const r = runId && board.db.prepare('SELECT task_id, ended_at, profile FROM task_runs WHERE id=?').get(runId);
106
+ // Blocking ends a run and a PR review retains its coder's conversation.
107
+ // The most recent worker can still lift its own blocker or queue its next PR;
108
+ // this never grants a superseded worker the current reviewer's run.
109
+ if (r?.task_id === own && r.ended_at != null && ['kanban_unblock', 'kanban_request_review'].includes(name)) {
110
+ const latest = board.db.prepare("SELECT id FROM task_runs WHERE task_id=? AND COALESCE(json_extract(metadata,'$.supercode.slot'),'worker')='worker' ORDER BY id DESC LIMIT 1").get(own);
111
+ if (latest?.id === runId && name === 'kanban_unblock' && t?.current_run_id == null) return null;
112
+ const review = name === 'kanban_request_review' && t?.stage === 'review'
113
+ ? json(board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(own)?.payload, {}) : null;
114
+ if (latest?.id === runId && review?.implementer === r.profile) return null;
115
+ }
76
116
  return !r || r.task_id !== own || r.ended_at != null || Number(t?.current_run_id) !== runId
77
117
  ? `run ${runId ?? '(missing)'} of ${own} is no longer its current run; nothing was changed` : null;
78
118
  };
@@ -80,14 +120,17 @@ export async function boardTool(root, name, args = {}, env = {}) {
80
120
  if (name !== 'kanban_show' && name !== 'kanban_list' && !(name === 'kanban_comment' && isLatestReviewer(board.db, tid, actorOf()))) { const stale = ownRun(); if (stale) return error(stale); }
81
121
  switch (name) {
82
122
  case 'kanban_show': {
83
- const card = tx(board, (db) => { const c = engine.showCard(db, tid, stateOptions); c.context = engine.cardContext(db, tid, stateOptions); return c; });
123
+ const card = tx(board, (db) => {
124
+ const review = own === tid && engine.reviewsCard(db, tid, { runId });
125
+ const c = engine.showCard(db, tid, { ...stateOptions, review }); c.context = engine.cardContext(db, tid, { ...stateOptions, review }); return c;
126
+ });
84
127
  const t = card.task;
85
128
  return JSON.stringify({
86
129
  task: { id: t.id, readable_id: t.readable_id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
87
130
  workspace_path: t.workspace_path, created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
88
131
  current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
89
132
  parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks,
90
- comments: card.comments, events: card.events.slice(-50), runs: card.runs.map((r) => ({ id: r.id, profile: r.profile, status: r.status, outcome: r.outcome, summary: r.summary, error: r.error, metadata: r.metadata, started_at: r.started_at, ended_at: r.ended_at })),
133
+ ...(card.review_request ? { review_request: card.review_request } : {}), pr_evidence: card.pr_evidence, comments: card.comments, events: card.events.slice(-50), runs: card.runs.map((r) => ({ id: r.id, profile: r.profile, status: r.status, outcome: r.outcome, summary: r.summary, error: r.error, metadata: r.metadata, started_at: r.started_at, ended_at: r.ended_at })),
91
134
  worker_context: card.context,
92
135
  });
93
136
  }
@@ -102,9 +145,8 @@ export async function boardTool(root, name, args = {}, env = {}) {
102
145
  const stale = ownRun(); if (stale) return error(stale);
103
146
  const summary = args.summary ? redact(String(args.summary)) : null;
104
147
  const result = args.result ? redact(String(args.result)) : null;
105
- if (!summary && !result) return error('provide at least one of: summary (preferred), result');
106
148
  let metadata = args.metadata === undefined || args.metadata === null ? null : redact(args.metadata);
107
- if (metadata !== null && (typeof metadata !== 'object' || Array.isArray(metadata))) return error('metadata must be an object');
149
+ if (metadata !== null && (typeof metadata !== 'object' || Array.isArray(metadata))) metadata = null;
108
150
  const artifacts = [args.artifacts].flat().filter((a) => typeof a === 'string' && a.trim()).map((a) => a.trim());
109
151
  if (artifacts.length) metadata = { ...(metadata ?? {}), artifacts: [...new Set([...(metadata?.artifacts ?? []), ...artifacts])] };
110
152
  // cards the handoff says it created must exist (Hermes refuses a handoff naming cards nobody made)
@@ -132,7 +174,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
132
174
  }
133
175
  case 'kanban_request_review': {
134
176
  const stale = ownRun(); if (stale) return error(stale);
135
- await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, plan: args.plan === true, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
177
+ await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, plan: args.plan === true, reviewer: args.reviewer ?? null, supersedes: args.supersedes ?? null, expectedRunId: own === tid && runId ? runId : null });
136
178
  return ok({ task_id: tid, status: 'review' });
137
179
  }
138
180
  case 'kanban_request_changes': {
@@ -145,12 +187,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
145
187
  }
146
188
  case 'kanban_heartbeat': {
147
189
  const stale = ownRun(); if (stale) return error(stale);
148
- await engine.heartbeat(board, tid, args.note ?? null);
149
- return ok({ task_id: tid });
190
+ await engine.heartbeat(board, tid, args.note ?? null, { until: args.until ?? null });
191
+ return ok({ task_id: tid, ...(args.until != null ? { until: args.until } : {}) });
150
192
  }
151
193
  case 'kanban_comment': {
152
194
  if (!String(args.body ?? '').trim()) return error('body is required');
153
- const n = await engine.comment(board, tid, isLatestReviewer(board.db, tid, actorOf()) ? actorOf() : author, redact(String(args.body)));
195
+ const n = await engine.comment(board, tid, isLatestReviewer(board.db, tid, actorOf()) ? actorOf() : author, redact(String(args.body)), { pr: args.pr ?? null, pr_head: args.pr_head ?? null, source: args.source ?? null, disposition: args.pr_disposition ?? null, release_target: args.release_target ?? null, readback: args.release_readback ?? null, reason: args.reason ?? null });
154
196
  return ok({ task_id: tid, comment_id: n });
155
197
  }
156
198
  case 'kanban_attach': case 'kanban_attach_url': {
@@ -194,7 +236,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
194
236
  return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
195
237
  }
196
238
  case 'kanban_unblock':
197
- return ok({ task_id: tid, status: (await engine.unblock(board, tid)).to });
239
+ return ok({ task_id: tid, status: (await engine.unblock(board, tid, { on: args.on ?? null, reason: args.reason ?? null })).to });
198
240
  case 'kanban_link':
199
241
  if (!args.parent_id || !args.child_id) return error('parent_id and child_id are required');
200
242
  await engine.link(board, args.parent_id, args.child_id);
@@ -203,7 +245,8 @@ export async function boardTool(root, name, args = {}, env = {}) {
203
245
  return error(`no board tool ${name}`);
204
246
  }
205
247
  } catch (e) {
206
- return error(`${name}: ${e.message}`);
248
+ if (e.errcode != null || /^(?:E[A-Z_]+|ERR_)/.test(e.code ?? '') || e instanceof TypeError || e instanceof ReferenceError) throw e;
249
+ return JSON.stringify({ error: `${name}: ${e.message}`, ...(e.ownerRule ? { ownerRule: true } : {}) });
207
250
  } finally {
208
251
  board.close();
209
252
  }
package/board/wake.mjs CHANGED
@@ -5,8 +5,13 @@ import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
8
- export function signalBoardWrite(root, actor) {
9
- if (!root || actor?.includes(':board:')) return;
8
+ // The homes this process dispatches (watchBoard). Its own work there (a round, a delivery pass) is the dispatcher's
9
+ // bookkeeping and wakes nothing, whatever actor its transaction records (ADR 0003, c40).
10
+ const dispatching = new Set();
11
+
12
+ /** Hint the home's dispatcher that a committed transaction changed its board. `own`: the process's own work. */
13
+ export function signalBoardWrite(root, actor, { own = false } = {}) {
14
+ if (!root || (own && dispatching.has(resolve(root)))) return;
10
15
  const path = join(root, '.dispatch-wake.json');
11
16
  const temporary = `${path}.${randomUUID()}.tmp`;
12
17
  try {
@@ -15,8 +20,13 @@ export function signalBoardWrite(root, actor) {
15
20
  } catch { try { unlinkSync(temporary); } catch { /* hints cannot fail a committed verb */ } }
16
21
  }
17
22
 
18
- /** One coalescing queue shared by standalone serve and the hosted dispatcher. */
19
- export function watchBoard({ root, binary, env, log = () => {}, onEvent = () => {}, mailWaiter = null }) {
23
+ /** One coalescing queue shared by standalone serve and the hosted dispatcher. `ownWrite(key, board, path)` says whether a
24
+ * store or document change is this dispatcher's own commit, by what was written (store.mjs ownBoardWrite): such a
25
+ * change still reaches the observer, so a held board reads it again, and only its wake is passed over (ADR 0003, c40). */
26
+ export function watchBoard({ root, binary, env, log = () => {}, onEvent = () => {}, mailWaiter = null, ownWrite = () => false }) {
27
+ const home = resolve(root);
28
+ dispatching.add(home);
29
+ const source = key => key === 'board-storage' || key.startsWith('document:');
20
30
  const watchers = new Map();
21
31
  let pending = null, waiter = null, closed = false;
22
32
  const wake = (source, at = Date.now(), info = {}) => {
@@ -45,7 +55,9 @@ export function watchBoard({ root, binary, env, log = () => {}, onEvent = () =>
45
55
  try {
46
56
  const handle = watch(path, (_event, filename) => {
47
57
  if (filename == null) { tell(key, { uncertain: true, board }); wake(key, undefined, { board }); return; }
48
- if (accept(String(filename))) wake(key, undefined, { board });
58
+ if (!accept(String(filename))) return;
59
+ if (source(key) && ownWrite(key, board, join(path, String(filename)))) { tell(key, { board }); return; }
60
+ wake(key, undefined, { board });
49
61
  });
50
62
  handle.on('error', error => {
51
63
  handle.close(); watchers.delete(id); unwatched.add(id);
@@ -130,6 +142,7 @@ export function watchBoard({ root, binary, env, log = () => {}, onEvent = () =>
130
142
  },
131
143
  close() {
132
144
  closed = true; waiter?.();
145
+ dispatching.delete(home);
133
146
  for (const handle of watchers.values()) handle.close();
134
147
  watchers.clear();
135
148
  },