@volter/supercode-orchestrator 0.5.228 → 0.5.230
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- 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
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
237
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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) => {
|
|
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)))
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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)))
|
|
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
|
},
|