@volter/supercode-orchestrator 0.5.228 → 0.5.230

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
@@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs';
6
6
  // supercode_publication_dirty, whoever writes (this process, Hermes's native writer, a document import). A transaction
7
7
  // publishes the marked cards and clears their marks; one that marked nothing reads one row and publishes nothing.
8
8
  import { createHash, randomUUID } from 'node:crypto';
9
- import { executionOf, readingContext } from './session-work.mjs';
9
+ import { executionOf, presenceFresh, readingContext } from './session-work.mjs';
10
10
  import { cardState } from './card-state.mjs';
11
11
  import { workflowFor } from './workflow.mjs';
12
12
  import { cardAgentScopeTransition } from '../agents.mjs';
@@ -40,8 +40,8 @@ CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_pub
40
40
  // is where a prune under way resumes, null when none is (prunePublications).
41
41
  export const PUBLICATION_COLUMNS = { supercode_publications: { snapshot_bytes: 'INTEGER' }, supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
42
42
 
43
- // The snapshot's shape: 1 names the card as a person does (`ref`, VO-856); 5 lists the files attached to it (`attachments`).
44
- const SNAPSHOT_SHAPE = 5;
43
+ // Snapshot shape: 1 names the card as a person does; 5 adds attachments; 6 adds attributed backlog and bounded pauses.
44
+ const SNAPSHOT_SHAPE = 6;
45
45
  const SNAPSHOT_RELEASE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
46
46
  const REVIEW_KIND_LIST = ['review_requested', 'approved', 'review_approved', 'changes_requested', 'escalated'];
47
47
  export const AGENT_KIND_LIST = ['agent_declared', 'agent_assignment_ended', 'agent_assignment_reassigned', 'agent_assignment_adopted'];
@@ -115,7 +115,7 @@ const RECLAIM_BYTES = 256 * 1024 * 1024;
115
115
  const hash = value => createHash('sha256').update(value).digest('hex');
116
116
  const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
117
117
  const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
118
- const kinds = {started:'started',created:'created',completed:'completed',cancelled:'cancelled',reopened:'reopened',blocked:'blocked',unblocked:'unblocked',paused:'paused',resumed:'resumed',assigned:'assigned',linked:'linked',unlinked:'unlinked',commented:'commented',specified:'specified',review_requested:'review_requested',changes_requested:'changes_requested'};
118
+ const kinds = {started:'started',created:'created',completed:'completed',cancelled:'cancelled',reopened:'reopened',blocked:'blocked',unblocked:'unblocked',paused:'paused',pause_extended:'pause_extended',resumed:'resumed',backlogged:'backlogged',planned:'planned',assigned:'assigned',linked:'linked',unlinked:'unlinked',commented:'commented',specified:'specified',review_requested:'review_requested',changes_requested:'changes_requested'};
119
119
 
120
120
  /**
121
121
  * The ontology's Task status (contracts/ontology.md: todo, running, review, done, cancelled) of a card in a board lane.
@@ -229,16 +229,41 @@ const fingerprintReads=db=>({
229
229
  // a run's metadata changes in place (its session's observations), so every run's snapshot columns are read whole
230
230
  runs:db.prepare('SELECT id,profile,step_key,status,started_at,ended_at,outcome,summary,metadata,error FROM task_runs WHERE task_id=? ORDER BY id'),
231
231
  });
232
+ /**
233
+ * A run as its card's snapshot reads it: its session's presence reading with whether it is current in place of when it
234
+ * was observed. The dispatcher renews an unchanged reading every minute (dispatch.mjs supervise); the snapshot carries
235
+ * what the reading says and whether it is current (publishedExecution), never the time it was renewed, so a renewal that
236
+ * changes neither is no change of the card. On the MacBook (2026-10-30) the renewal alone republished each running card
237
+ * whole every minute (1.95 MB for t_2a2ef475).
238
+ */
239
+ function presenceRead(run,now){
240
+ if(typeof run.metadata!=='string'||!run.metadata.includes('"presence_observation"'))return run;
241
+ const metadata=parse(run.metadata,null),reading=metadata?.supercode?.presence_observation;
242
+ if(!reading||typeof reading!=='object')return run;
243
+ const {observed_at,...said}=reading;
244
+ return {...run,metadata:JSON.stringify({...metadata,supercode:{...metadata.supercode,presence_observation:{...said,current:presenceFresh(reading,now)}}})};
245
+ }
232
246
  /**
233
247
  * A card's fingerprint: what its snapshot is built from, read cheaply (no comment text, review or agent event payloads,
234
248
  * and no snapshot built), with the snapshot's shape, its board's key and the options it is built with. Equal
235
- * fingerprints build equal snapshots, but for a run's session observation passing its freshness (executionOf), which a
236
- * snapshot's readers apply themselves.
249
+ * fingerprints build equal snapshots, but for a bounded wait (a heartbeat's `until`) passing, which a snapshot's readers
250
+ * apply themselves.
237
251
  */
238
252
  function fingerprintOf(reads,rows,row,key,options){
239
253
  const task={};for(const [column,value] of Object.entries(row))if(!UNREAD_TASK_COLUMNS.has(column))task[column]=value;
240
254
  return hash(JSON.stringify([SNAPSHOT_SHAPE,SNAPSHOT_RELEASE,key,Boolean(options.nativeHolds),task,rows.own(row.id)??null,rows.number(row.id),rows.parents(row.id),rows.children(row.id),
241
- reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
255
+ reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id).map(run=>presenceRead(run,options.now))]));
256
+ }
257
+ /**
258
+ * A card's session reading as its snapshot publishes it: what the board last read of the session (its state, turn,
259
+ * machine and reason), without the time of that reading or when it stops being current. Those move with every renewal;
260
+ * the board's own doors (`workflow list`, `workflow show`: listing.mjs) read them live, and no reader of a published
261
+ * card reads them (the connector's publishableCard, the Workplace's cards, `workflow events`).
262
+ */
263
+ export function publishedExecution(execution){
264
+ if(!execution||typeof execution!=='object')return execution;
265
+ const {observed_at,fresh_until,...said}=execution;
266
+ return said;
242
267
  }
243
268
  /** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
244
269
  * (`VO-856`, store.mjs cardRef), null for a card with no number. */
@@ -269,6 +294,7 @@ function supersede(db,sequence){
269
294
  }
270
295
 
271
296
  export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
297
+ const now=options.now??Date.now()/1000;
272
298
  const own=rows.own(row.id)??{},number=rows.number(row.id);
273
299
  const comments=rows.comments(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
274
300
  const verdicts={review_requested:'requested',approved:'approved',review_approved:'approved',changes_requested:'changes_requested',escalated:'escalated'};
@@ -278,16 +304,16 @@ export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
278
304
  const native=parse(run.metadata,{})?.supercode??null;
279
305
  const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,detached_at:native.detached_at??null,detached_to:native.detached_to??null,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
280
306
  const metadata=parse(run.metadata,{});delete metadata.supercode;
281
- return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:executionOf(run),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
307
+ return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:publishedExecution(executionOf(run,{now})),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
282
308
  });
283
309
  const lane=own.stage??row.status;
284
310
  const archived=Boolean(own.archived || lane==='archived');
285
311
  // a subtask, a row under its card that no person names, is published with no readable id, as the board shows it
286
- return {id:row.id,readable_id:own.subtask_of?null:row.readable_id??null,number,ref:key&&number?`${key}-${number}`:null,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),board_status:cardState({...row,stage:lane,paused:own.paused,blocked_by:own.blocked_by},options).status,lane,
287
- execution:executionOf(runs.find(run=>run.id===row.current_run_id),readingContext(db,row.id,row.current_run_id)),
312
+ return {id:row.id,readable_id:own.subtask_of?null:row.readable_id??null,number,ref:key&&number?`${key}-${number}`:null,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),board_status:cardState({...row,stage:lane,paused:own.paused,backlog:own.backlog,blocked_by:own.blocked_by},options).status,lane,
313
+ execution:publishedExecution(executionOf(runs.find(run=>run.id===row.current_run_id),{...readingContext(db,row.id,row.current_run_id),now})),
288
314
  priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
289
315
  tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived,archive_reason:archived?rows.archiveReason(row.id):null,
290
- blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),detached:parse(own.detached),every:own.every??null,
316
+ blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),backlog:parse(own.backlog),detached:parse(own.detached),every:own.every??null,
291
317
  workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
292
318
  parents:rows.parents(row.id),
293
319
  agents:rows.agents(row.id),reviews,children:rows.children(row.id),comments,attempts,
@@ -303,7 +329,9 @@ export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
303
329
  * compared again. The marks are cleared with it. Retention is the owner's (prunePublications), never a transaction's.
304
330
  */
305
331
  export function recordCardPublications(db,actor,root,key) {
306
- const options={nativeHolds:Boolean(workflowFor(root,{optional:true})?.workflow.flags?.paused)};
332
+ // Fingerprint and snapshot must agree at the freshness boundary: caching a current fingerprint beside an expired
333
+ // snapshot would also skip the next unchanged renewal, leaving the card unknown after its session was read again.
334
+ const options={now:Date.now()/1000,nativeHolds:Boolean(workflowFor(root,{optional:true})?.workflow.flags?.paused)};
307
335
  const marked=db.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map(row=>row.task_id);
308
336
  if(!marked.length)return 0;
309
337
  db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
package/board/queries.mjs CHANGED
@@ -2,10 +2,30 @@
2
2
  import { setImmediate as yieldToOwner } from 'node:timers/promises';
3
3
 
4
4
  /** One reverse index seek per kind, then one payload read. ORDER BY id over an IN-kind filter can instead choose
5
- * the task/id index and read unrelated event payloads to reject their kinds. Kinds and columns are code constants. */
6
- export function latestEvent(db, task, kinds, columns = 'payload') {
7
- const seeks = kinds.map(kind => `SELECT (SELECT id FROM task_events WHERE task_id=? AND kind='${kind}' ORDER BY id DESC LIMIT 1) AS id`);
8
- return db.prepare(`SELECT ${columns} FROM task_events WHERE id=(SELECT MAX(id) FROM (${seeks.join(' UNION ALL ')}))`).get(...kinds.map(() => task));
5
+ * the task/id index and read unrelated event payloads to reject their kinds. SQL fragments are code constants. */
6
+ export function latestEventIdSql(task, kinds, predicate = '1') {
7
+ const seeks = kinds.map(kind => `SELECT (SELECT id FROM task_events WHERE task_id=${task} AND kind='${kind}' AND (${predicate}) ORDER BY id DESC LIMIT 1) AS id`);
8
+ return `(SELECT MAX(id) FROM (${seeks.join(' UNION ALL ')}))`;
9
+ }
10
+
11
+ export function latestEvent(db, task, kinds, columns = 'payload', predicate = '1', args = []) {
12
+ return db.prepare(`SELECT ${columns} FROM task_events WHERE id=${latestEventIdSql('?', kinds, predicate)}`).get(...kinds.flatMap(() => [task, ...args]));
13
+ }
14
+
15
+ /** The latest dispatch wait for task `t` and card `c`, after its stage entry and latest run. Stage-entry and run event
16
+ * ids supply ordering when their timestamps and an older wait fall in the same second. Each history read is indexed. */
17
+ export const DISPATCH_WAIT_SQL = `SELECT CASE WHEN json_valid(e.payload) THEN json_object('reason',json_extract(e.payload,'$.reason'),'at',e.created_at) END
18
+ FROM task_events e WHERE t.current_run_id IS NULL AND COALESCE(c.stage,t.status) NOT IN ('done','archived','cancelled')
19
+ AND e.id=(SELECT id FROM task_events WHERE task_id=t.id AND kind='respawn_guarded' ORDER BY id DESC LIMIT 1)
20
+ AND e.created_at>=COALESCE(c.since,0)
21
+ AND e.id>COALESCE((SELECT id FROM task_events WHERE task_id=t.id AND kind='stage_entered' ORDER BY id DESC LIMIT 1),0)
22
+ AND e.created_at>=COALESCE((SELECT COALESCE(ended_at,started_at) FROM task_runs WHERE task_id=t.id ORDER BY id DESC LIMIT 1),0)
23
+ AND e.id>COALESCE((SELECT id FROM task_events WHERE run_id=(SELECT id FROM task_runs WHERE task_id=t.id ORDER BY id DESC LIMIT 1) ORDER BY id LIMIT 1),0)`;
24
+
25
+ /** The dispatcher's retained reason for a card whose next run cannot start, or null. */
26
+ export function dispatchWait(db, taskId) {
27
+ const row = db.prepare(`SELECT (${DISPATCH_WAIT_SQL}) AS wait FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?`).get(taskId);
28
+ return JSON.parse(row?.wait ?? 'null');
9
29
  }
10
30
 
11
31
  /** Read run history in finite, indexed pages, yielding before each page. The candidate page precedes any JSON
@@ -1,12 +1,14 @@
1
1
  // Resident local consumer of the daemon's committed record rows. Disconnects
2
2
  // preserve the last applied cursor, but held data is stale until caught up.
3
3
  import { jsonObjectReader } from '@volter/teams/lines';
4
+ import { SECTION_PARTS_CODEC, eventSection, sectionParts, hydrateSectionParts } from '@volter/supercode-teams/record-section-parts';
4
5
 
5
6
  export class RecordView {
6
7
  constructor(open, { progressMs = 10_000 } = {}) {
7
8
  this.open = open; this.records = {}; this.positions = new Map();
8
9
  this.cursor = null; this.stream = null; this.waiters = new Set();
9
10
  this.pending = [];
11
+ this.sectionRows = new Map(); this.dirtySections = new Map();
10
12
  this.ready = false; this.heardAt = 0;
11
13
  this.progressMs = progressMs; this.progressTimer = null;
12
14
  }
@@ -27,7 +29,7 @@ export class RecordView {
27
29
  }
28
30
  connect() {
29
31
  if (this.stream) return Promise.resolve();
30
- return this.opening ??= Promise.resolve().then(() => this.open({ after: this.cursor })).then((stream) => {
32
+ return this.opening ??= Promise.resolve().then(() => this.open({ after: this.cursor, record_row_codecs: [SECTION_PARTS_CODEC] })).then((stream) => {
31
33
  this.stream = stream; this.ready = false; this.heardAt = Date.now();
32
34
  const readObject = jsonObjectReader();
33
35
  stream.on('data', (frame) => {
@@ -82,7 +84,12 @@ export class RecordView {
82
84
  if (frame.type === 'beat') { this.heardAt = Date.now(); return; }
83
85
  if (frame.type === 'snapshot') {
84
86
  this.self = frame.machine; this.records = frame.records; this.positions.clear(); this.pending = [];
85
- for (const [machine, held] of Object.entries(this.records)) this.positions.set(machine, new Map((held.record?.sessions ?? []).map((row, at) => [row.address, at])));
87
+ this.sectionRows.clear(); this.dirtySections.clear();
88
+ for (const [machine, held] of Object.entries(this.records)) {
89
+ this.positions.set(machine, new Map((held.record?.sessions ?? []).map((row, at) => [row.address, at])));
90
+ this.sectionRows.set(machine, new Map(['fleet', 'health_events'].filter(section => Object.hasOwn(held.record ?? {}, section))
91
+ .map(section => [section, sectionParts(held.record[section])])));
92
+ }
86
93
  this.cursor = frame.cursor; this.ready = true;
87
94
  } else if (frame.type === 'changes') {
88
95
  if (!this.cursor || frame.cursor?.id !== this.cursor.id) throw new Error('record row changes name a different database; a new snapshot is required');
@@ -99,11 +106,12 @@ export class RecordView {
99
106
  if (change.sequence <= this.cursor.sequence || !this.own(change.key)) continue;
100
107
  const { machine, section, key, position } = change.key;
101
108
  if (change.topic === 'record-head') {
102
- if (change.operation === 'delete') { delete this.records[machine]; this.positions.delete(machine); continue; }
109
+ if (change.operation === 'delete') { delete this.records[machine]; this.positions.delete(machine); this.sectionRows.delete(machine); this.dirtySections.delete(machine); continue; }
103
110
  const { record: summary, meta } = change.value;
104
111
  const previous = this.records[machine]?.record;
105
112
  const record = summary == null ? null : { sessions: [], mail: {}, panes: null, capabilities: null, unread: {}, ...previous, ...summary };
106
113
  if (record) { if (!summary.sessions_rows) record.sessions = null; delete record.sessions_rows; }
114
+ else this.sectionRows.delete(machine);
107
115
  this.records[machine] = { record, stream: machine === this.self ? 'self' : meta?.stream ?? 'closed', since: meta?.since ?? null, why: meta?.why ?? null, received_at: meta?.received_at ?? null };
108
116
  } else if (change.topic === 'record-row') {
109
117
  const held = this.records[machine] ??= { stream: 'closed', record: null };
@@ -118,6 +126,13 @@ export class RecordView {
118
126
  record.sessions = [...rows.values()]; moved.add(machine);
119
127
  } else if (section === 'mail') {
120
128
  if (deleted) delete record.mail[key]; else record.mail[key] = change.value;
129
+ } else if (eventSection(section)) {
130
+ const sections = this.sectionRows.get(machine) ?? new Map(); this.sectionRows.set(machine, sections);
131
+ let rows = sections.get(section) ?? sectionParts(record[section]);
132
+ if (key === '') { rows = new Map(); if (!deleted) rows.set('', { key, position, value: change.value }); }
133
+ else { rows.delete(''); if (deleted) rows.delete(key); else rows.set(key, { key, position, value: change.value }); }
134
+ sections.set(section, rows);
135
+ const dirty = this.dirtySections.get(machine) ?? new Set(); this.dirtySections.set(machine, dirty); dirty.add(section);
121
136
  } else record[section] = deleted ? null : change.value;
122
137
  }
123
138
  }
@@ -125,6 +140,18 @@ export class RecordView {
125
140
  const positions = this.positions.get(machine);
126
141
  this.records[machine]?.record?.sessions?.sort((a, b) => (positions.get(a.address) ?? 0) - (positions.get(b.address) ?? 0) || a.address.localeCompare(b.address));
127
142
  }
143
+ // A bounded page can end inside a writer transaction. Keep its row
144
+ // reconstruction until caught up, when every header/event change is in.
145
+ if (!frame.more) {
146
+ for (const [machine, sections] of this.dirtySections) {
147
+ const record = this.records[machine]?.record;
148
+ if (record) for (const section of sections) {
149
+ const rows = this.sectionRows.get(machine)?.get(section);
150
+ record[section] = rows?.size ? hydrateSectionParts([...rows.values()]) : null;
151
+ }
152
+ }
153
+ this.dirtySections.clear();
154
+ }
128
155
  this.cursor = frame.cursor; this.ready = !frame.more;
129
156
  } else throw new Error(`unrecognized record-view frame ${frame.type}`);
130
157
  this.heardAt = Date.now();
@@ -1,13 +1,44 @@
1
1
  import { event, json, now } from './store.mjs';
2
+ import { currentRecordChanges, currentRecordConnection } from './current-records.mjs';
3
+
4
+ const heldMail = new WeakMap();
5
+ const MAIL_KINDS = "('board_message','board_message_sent','board_message_passed','board_message_batch')";
6
+ /** Unsettled mail and its retained envelopes; new events update this small set. */
7
+ export function pendingRequestRows(db) {
8
+ let held = heldMail.get(currentRecordConnection(db));
9
+ const change = currentRecordChanges(db, held?.token);
10
+ const high = Number(db.prepare('SELECT MAX(id) AS id FROM task_events').get()?.id ?? 0);
11
+ if (!held || change.eventsRewritten || high < held.cursor) {
12
+ const pending = db.prepare(`SELECT id,task_id,payload,created_at FROM task_events original
13
+ WHERE kind='board_message' AND NOT EXISTS (SELECT 1 FROM task_events sent
14
+ WHERE sent.kind IN ('board_message_sent','board_message_passed') AND sent.task_id=original.task_id
15
+ AND json_extract(CASE WHEN json_valid(sent.payload) THEN sent.payload END,'$.event')=original.id) ORDER BY id`).all()
16
+ .map(row => ({ ...row, mail: json(row.payload, {}) ?? {} }));
17
+ const batches = pending.length ? db.prepare("SELECT id,payload FROM task_events WHERE kind='board_message_batch' AND id>=? ORDER BY id").all(pending[0].id) : [];
18
+ held = { pending: new Map(pending.map(row => [row.id,row])), batches: new Map(batches.map(row => [row.id,json(row.payload,{})])), cursor: high };
19
+ heldMail.set(currentRecordConnection(db), held);
20
+ } else {
21
+ while (held.cursor < high) {
22
+ const rows = db.prepare(`SELECT id,task_id,kind,payload,created_at FROM task_events
23
+ WHERE id>? AND id<=? AND kind IN ${MAIL_KINDS} ORDER BY id LIMIT 64`).all(held.cursor,high);
24
+ if (!rows.length) { held.cursor = high; break; }
25
+ for (const row of rows) {
26
+ const payload = json(row.payload,{}) ?? {};
27
+ if (row.kind === 'board_message') held.pending.set(row.id,{ ...row,mail: payload });
28
+ else if (row.kind === 'board_message_batch') held.batches.set(row.id,payload);
29
+ else if (held.pending.get(payload.event)?.task_id === row.task_id) held.pending.delete(payload.event);
30
+ }
31
+ held.cursor = rows.at(-1).id;
32
+ }
33
+ }
34
+ for (const [id,batch] of held.batches) if (!batch.members?.some(member => held.pending.has(member.id))) held.batches.delete(id);
35
+ held.token = change.token;
36
+ return { pending: [...held.pending.values()].sort((a,b) => a.id-b.id), batches: [...held.batches.values()] };
37
+ }
2
38
 
3
39
  export function pendingRequestMail(db, taskId = null) {
4
- const pending = db.prepare(`SELECT id, task_id, payload, created_at FROM task_events original
5
- WHERE kind='board_message' AND NOT EXISTS (SELECT 1 FROM task_events sent WHERE sent.kind IN ('board_message_sent', 'board_message_passed')
6
- AND sent.task_id=original.task_id AND json_extract(sent.payload,'$.event')=original.id) ORDER BY id`).all()
7
- .map(row => ({ ...row, mail: json(row.payload, {}) ?? {} }));
40
+ const { pending, batches } = pendingRequestRows(db);
8
41
  if (!pending.length) return { ready: [], nextAt: Infinity };
9
- const batches = db.prepare("SELECT payload FROM task_events WHERE kind='board_message_batch' AND id>=? ORDER BY id").all(pending[0].id)
10
- .map(row => json(row.payload, {}));
11
42
  const assigned = new Set();
12
43
  const ready = [];
13
44
  let nextAt = Infinity;
@@ -0,0 +1,318 @@
1
+ // Reader-first native hold admission. The actual stable owner's selected source, never a caller flag, decides
2
+ // whether this generation may interpret fields that a legacy rollback would ignore or lose.
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
6
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { askBoardOwner } from '@volter/teams/board-owner';
9
+ import { nativeJsonEqual, parseNativeJson } from '@volter/supercode-interchange/native-json';
10
+ import { readSourceCandidate, readSourceRecord } from '@volter/supercode-teams/source-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
11
+ import { sourceLockPath } from '@volter/supercode-teams/source-lock'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
12
+ import { readRetainedRelease } from '@volter/supercode-teams/release-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
13
+ import { installJournalPath, readInstallJournal } from '@volter/supercode-teams/machine-install-journal'; // layer: the board's resting-reader admission reads the host's durable install decision; owed: the host hands it in
14
+ import { restingContract } from './resting-protocol.mjs';
15
+ import { assertGenerationWriter } from './generation.mjs';
16
+ import { bindRestingReader, assertRestingInput, RESTING_WRITE_MODE } from './resting-state.mjs';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const MODE = 'backlog-bounded-pause-v1';
20
+ let admission = null;
21
+ const digest = path => createHash('sha256').update(readFileSync(path)).digest('hex');
22
+ const coreSame = (left, right) => ['root', 'commit', 'supercode', 'binding', 'graph'].every(key =>
23
+ nativeJsonEqual(left?.[key] ?? null, right?.[key] ?? null));
24
+ function optionalJson(path) {
25
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
26
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
27
+ }
28
+ function selectedReader(selected, env) {
29
+ if (!selected || typeof selected.entry !== 'string' || !isAbsolute(selected.entry))
30
+ throw new Error('board resting-state admission has no complete incumbent entry');
31
+ const entry = realpathSync(selected.entry);
32
+ // Unretained mutable checkout paths name no verified predecessor capability.
33
+ if (!selected.candidate && !selected.release) return null;
34
+ if (selected.candidate && selected.release) throw new Error('board incumbent names two source selections');
35
+ if (selected.candidate) {
36
+ const source = readSourceCandidate(realpathSync(selected.candidate), sourceLockPath(env));
37
+ if (entry !== realpathSync(join(source.value.lock.root, 'sdk/orchestrator/bin/orchestrator.mjs')) ||
38
+ realpathSync(selected.native) !== realpathSync(source.program)) throw new Error('board incumbent source binding changed');
39
+ } else if (selected.release) {
40
+ const release = readRetainedRelease(selected.release);
41
+ if (entry !== realpathSync(release.board) || realpathSync(selected.native) !== realpathSync(release.program))
42
+ throw new Error('board incumbent release binding changed');
43
+ }
44
+ return restingContract(entry, { optional: true });
45
+ }
46
+
47
+ // Only the current lock-holding owner lends this record, captured before it overwrites its predecessor's record.
48
+ // An ef4 owner writes the older complete shape before activation and removes it on stop, so it cannot carry a stale
49
+ // newer-reader claim through a legacy write, even when the next source has the same entry path or commit.
50
+ function recordedReader(status, env) {
51
+ try {
52
+ const prior = status.restingReaderProtocol === 1 && status.lastRestingReader;
53
+ if (prior?.version !== 1 || !prior.selected || !prior.resting) return null;
54
+ const actual = selectedReader(prior.selected, env);
55
+ return actual && nativeJsonEqual(actual, prior.resting) ? prior.selected : null;
56
+ } catch { return null; }
57
+ }
58
+
59
+ function recordGrantedReader(env) {
60
+ const request = randomUUID();
61
+ return new Promise((done, reject) => {
62
+ let timer;
63
+ const finish = error => {
64
+ clearTimeout(timer); process.off('message', receive); process.off('disconnect', disconnected);
65
+ error ? reject(error) : done();
66
+ };
67
+ const receive = frame => {
68
+ if (frame?.type !== 'resting-reader-recorded' || frame.request !== request || frame.generation !== env.SUPERCODE_BOARD_GENERATION_ID) return;
69
+ finish(frame.error ? new Error(`board reader selection was not recorded: ${frame.error}`) : null);
70
+ };
71
+ const disconnected = () => finish(new Error('board owner disconnected before recording its actual reader'));
72
+ process.on('message', receive); process.once('disconnect', disconnected);
73
+ timer = setTimeout(() => finish(new Error('board owner did not record its actual reader within 8 seconds')), 8000);
74
+ try { process.send({ type: 'resting-writer-granted', request, generation: env.SUPERCODE_BOARD_GENERATION_ID }); }
75
+ catch (error) { finish(error); }
76
+ });
77
+ }
78
+
79
+ // A fresh stable owner has no active child yet. The install's durable source decision is existing selection
80
+ // history, rather than an encoding-admitted boolean an old generation could leave behind on rollback. Source
81
+ // commit follows every component's activation; restoration names the exact predecessor that recovery must start.
82
+ // Cleanup can still be unfinished: it must not strand a cold owner on text the selected reader already supports.
83
+ // The decision, actual primary, prepared home and this invocation's complete selection must all agree. A merely
84
+ // prepared/uncommitted decision grants nothing, and missing/stale history falls back to the legacy collision scan.
85
+ function installDecisionReader(root, env) {
86
+ const ledger = sourceLockPath(env), home = env.SUPERCODE_TEAMS_HOME ?? join(dirname(ledger), 'teams'), service = join(home, 'service');
87
+ const journal = readInstallJournal(installJournalPath(home), { optional: true });
88
+ if (!journal) return null;
89
+ try {
90
+ if (journal.preparedInputs === undefined) return null;
91
+ const rows = journal.boards.filter(board => board.home === root);
92
+ if (rows.length !== 1) return null;
93
+ const board = rows[0], prepared = journal.preparedInputs;
94
+ const recorded = prepared.boards?.filter(item => item?.home === board.home);
95
+ if (recorded?.length !== 1 || !nativeJsonEqual(recorded[0].before?.selected, board.before?.selected) ||
96
+ !nativeJsonEqual(recorded[0].predecessorReceipts, board.predecessorReceipts) ||
97
+ prepared.candidate !== journal.candidate || prepared.published !== journal.published ||
98
+ !/^[a-f0-9]{64}$/.test(prepared.candidateSha256 ?? '') || digest(journal.candidate) !== prepared.candidateSha256) return null;
99
+ let selected, committed;
100
+ if (journal.published) {
101
+ const release = readRetainedRelease(journal.candidate), decision = optionalJson(join(service, 'release-handover.json'));
102
+ if (decision?.transaction !== journal.transaction || decision.candidate !== journal.candidate ||
103
+ !['committed', 'restored'].includes(decision.state)) return null;
104
+ committed = decision.state === 'committed';
105
+ selected = committed ? { entry: release.board, native: release.program, release: journal.candidate, candidate: null } : board.before.selected;
106
+ if (!selected?.release || selected.candidate) return null;
107
+ const expected = readRetainedRelease(selected.release), primary = optionalJson(join(service, 'active-release.json'));
108
+ const outcome = committed ? decision.next : JSON.parse(decision.previous_text);
109
+ if (!nativeJsonEqual(primary, outcome) ||
110
+ !['root', 'native', 'entry', 'release'].every(key => primary?.release?.[key] === expected[key])) return null;
111
+ const own = env.SUPERCODE_RELEASE_RECEIPT && readRetainedRelease(env.SUPERCODE_RELEASE_RECEIPT);
112
+ if (!own || env.SUPERCODE_SOURCE_CANDIDATE || own.receipt !== expected.receipt) return null;
113
+ } else {
114
+ const candidate = readSourceCandidate(journal.candidate, ledger);
115
+ const decision = readSourceRecord(join(dirname(candidate.path), 'activation.json'));
116
+ if (decision.transaction !== journal.transaction || decision.candidate !== candidate.path ||
117
+ !['committed', 'restored'].includes(decision.state) || !coreSame(decision.next, candidate.value.lock)) return null;
118
+ committed = decision.state === 'committed';
119
+ selected = committed ? { entry: join(candidate.value.lock.root, 'sdk/orchestrator/bin/orchestrator.mjs'),
120
+ native: candidate.program, candidate: candidate.path, release: null } : board.before.selected;
121
+ if (!selected?.candidate || selected.release) return null;
122
+ const expected = readSourceCandidate(selected.candidate, ledger);
123
+ const primary = readSourceRecord(ledger), outcome = committed ? decision.next : parseNativeJson(decision.previous_text);
124
+ if (!nativeJsonEqual(primary, outcome) || !coreSame(primary, expected.value.lock)) return null;
125
+ const own = env.SUPERCODE_SOURCE_CANDIDATE && readSourceCandidate(env.SUPERCODE_SOURCE_CANDIDATE, ledger);
126
+ if (!own || env.SUPERCODE_RELEASE_RECEIPT || !coreSame(own.value.lock, expected.value.lock)) return null;
127
+ }
128
+ if (!committed) for (const receipt of Object.values(board.predecessorReceipts ?? {})) {
129
+ if (!receipt || !/^[a-f0-9]{64}$/.test(receipt.sha256 ?? '') || digest(receipt.path) !== receipt.sha256) return null;
130
+ }
131
+ if (!env.SUPERCODE_ORCHESTRATOR_ENTRY || realpathSync(env.SUPERCODE_ORCHESTRATOR_ENTRY) !== realpathSync(selected.entry) ||
132
+ !env.SUPERCODE_BIN || realpathSync(env.SUPERCODE_BIN) !== realpathSync(selected.native)) return null;
133
+ return selected;
134
+ } catch { return null; }
135
+ }
136
+
137
+ // Enumerate storage declarations, not dispatchable boards: an archived board and every rotated archive still
138
+ // belong to the home. Read only the old projection's inventory and pending bytes, never openBoard or a new parser.
139
+ function boardDocuments(root, inspect) {
140
+ const markers = [], base = join(root, 'kanban'), named = join(base, 'boards');
141
+ if (existsSync(join(base, 'ztrack.json'))) markers.push(join(base, 'ztrack.json'));
142
+ if (existsSync(named)) for (const slug of readdirSync(named)) {
143
+ const marker = join(named, slug, 'ztrack.json');
144
+ if (existsSync(marker)) markers.push(marker);
145
+ }
146
+ for (const marker of markers) {
147
+ const config = optionalJson(marker);
148
+ if (!config || typeof config.document !== 'string') throw new Error(`board resting-state admission: invalid document declaration ${marker}`);
149
+ const document = resolve(dirname(marker), config.document), directory = dirname(document);
150
+ const paths = new Set([document, `${document}.archive.md`]);
151
+ if (existsSync(directory)) for (const name of readdirSync(directory))
152
+ if (name.startsWith(`${basename(document)}.archive-`) && name.endsWith('.md')) paths.add(join(directory, name));
153
+ let pending = null;
154
+ const projection = `${marker}.board.db`;
155
+ if (existsSync(projection) && statSync(projection).size) {
156
+ const db = new DatabaseSync(projection, { readOnly: true });
157
+ try {
158
+ db.exec('BEGIN');
159
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='board_state'").get()) {
160
+ const state = key => {
161
+ const row = db.prepare('SELECT value FROM board_state WHERE key=?').get(key);
162
+ return row ? JSON.parse(row.value) : null;
163
+ };
164
+ const archives = state('archives') ?? [];
165
+ if (!Array.isArray(archives)) throw new Error(`board resting-state admission: invalid archive inventory ${projection}`);
166
+ for (const archive of archives) {
167
+ if (!archive || typeof archive.name !== 'string' || archive.name !== basename(archive.name))
168
+ throw new Error(`board resting-state admission: invalid archive path ${projection}`);
169
+ paths.add(join(directory, archive.name));
170
+ }
171
+ pending = state('pending');
172
+ }
173
+ } finally { db.close(); }
174
+ }
175
+ for (const path of paths) {
176
+ let text;
177
+ try { text = readFileSync(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
178
+ inspect(text, path);
179
+ }
180
+ if (pending !== null) {
181
+ if (!Array.isArray(pending.files)) throw new Error(`board resting-state admission: invalid pending publication ${projection}`);
182
+ for (const file of pending.files) {
183
+ if (!file || typeof file.path !== 'string' || !paths.has(resolve(file.path)) || typeof file.base !== 'string' || typeof file.text !== 'string')
184
+ throw new Error(`board resting-state admission: invalid pending document ${projection}`);
185
+ inspect(file.base, `${file.path} (pending base)`);
186
+ inspect(file.text, `${file.path} (pending publication)`);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+
193
+ export function assertBoardRestingDocument(text, path) {
194
+ if (!admission || admission.compatible) return;
195
+ // Preparation and read-only snapshots inspect the same archived documents. Reuse only a successful
196
+ // observation of these exact text bytes at this path; a changed document is inspected again before use.
197
+ if (admission.inspected.get(path) === text) return;
198
+ const slots = admission.inspect(text, path);
199
+ if (!Array.isArray(slots)) throw new Error('board codec returned no resting-state slot inventory');
200
+ if (slots.length) throw new Error(`board resting-state admission: new reader metadata has no compatible incumbent reader in ${path}:${slots[0].line}`);
201
+ admission.inspected.set(path, text);
202
+ }
203
+
204
+ function inspectRows(db, path) {
205
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_cards'").get()) return;
206
+ const hasBacklog = db.prepare('PRAGMA table_info(supercode_cards)').all().some(column => column.name === 'backlog');
207
+ for (const row of db.prepare(`SELECT task_id,paused,${hasBacklog ? 'backlog' : 'NULL AS backlog'} FROM supercode_cards`).all())
208
+ assertRestingInput(row.paused, row.backlog, { source: `${path}:${row.task_id}` });
209
+ }
210
+
211
+ // Scan every backing, archived board and pending publication before granting a writer. No schema migration or
212
+ // modern parser runs here; the incumbent's raw declarations and rows are the inventory.
213
+ function inspectHome(root) {
214
+ if (admission?.compatible) return;
215
+ const base = join(root, 'kanban'), named = join(base, 'boards');
216
+ const places = [{ directory: base, sqlite: join(root, 'kanban.db') }];
217
+ if (existsSync(named)) for (const name of readdirSync(named)) {
218
+ const directory = join(named, name);
219
+ if (statSync(directory).isDirectory()) places.push({ directory, sqlite: join(directory, 'kanban.db') });
220
+ }
221
+ for (const { directory, sqlite } of places) {
222
+ const files = join(directory, 'board');
223
+ if (existsSync(join(files, '_board.json'))) {
224
+ for (const name of readdirSync(files)) {
225
+ if (!name.endsWith('.json')) continue;
226
+ const path = join(files, name), card = optionalJson(path);
227
+ for (const row of card?.tables?.supercode_cards ?? []) assertRestingInput(row.paused, row.backlog, { source: path });
228
+ }
229
+ }
230
+ for (const path of [sqlite, join(directory, 'ztrack.json.board.db')]) {
231
+ if (!existsSync(path) || !statSync(path).size) continue;
232
+ const db = new DatabaseSync(path, { readOnly: true });
233
+ try { db.exec('BEGIN'); inspectRows(db, path); } finally { db.close(); }
234
+ }
235
+ }
236
+ boardDocuments(root, assertBoardRestingDocument);
237
+ }
238
+
239
+ // A retained final source can be its own first reader, before a board exists. An initialized or previously
240
+ // used home is never inferred fresh from empty task rows. A fencing counter alone carries no resting data.
241
+ // The only generation record allowed during activation is this
242
+ // owner's provisional selection for this exact child; it is not itself a durable reader acknowledgement.
243
+ export function pristineHome(root, { owner, generation, selected, provisional = false, epoch } = {}) {
244
+ if (['kanban', 'kanban.db', 'kanban.db-wal', 'kanban.db-shm'].some(name => existsSync(join(root, name)))) return false;
245
+ let recordedEpoch = null;
246
+ try {
247
+ const text = readFileSync(join(root, 'board.epoch'), 'utf8');
248
+ recordedEpoch = Number(text);
249
+ if (!Number.isSafeInteger(recordedEpoch) || recordedEpoch < 1 || text !== `${recordedEpoch}\n`) return false;
250
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
251
+ // Admission may encounter an abandoned pre-writer fence. Activation must name this invocation's new one.
252
+ if (epoch !== undefined && (!Number.isSafeInteger(epoch) || epoch < 1 || recordedEpoch !== epoch)) return false;
253
+ const recordPath = join(root, 'board-generation.json');
254
+ if (!existsSync(recordPath)) return true;
255
+ const record = optionalJson(recordPath);
256
+ return provisional && record?.version === 1 && record.owner === owner && record.active === process.pid && record.generation === generation &&
257
+ nativeJsonEqual(record.selected, selected) && record.lastRestingReader == null && record.previous == null &&
258
+ Array.isArray(record.unended) && record.unended.length === 0;
259
+ }
260
+
261
+ export async function prepareBoardRestingAdmission(root, env = process.env) {
262
+ if (admission) throw new Error('board resting-state admission is already captured for this generation');
263
+ root = realpathSync(root);
264
+ const answer = await askBoardOwner(root, { argv: ['handover', '--action', 'status'] }, { waitMs: 0, timeoutMs: 8000 });
265
+ if (answer.code !== 0 || answer.unanswered) throw new Error('board resting-state admission: incumbent owner status is unknown');
266
+ const status = JSON.parse(Array.isArray(answer.stdout) ? answer.stdout.join('') : answer.stdout);
267
+ if (status.protocol !== 1 || status.owner !== process.ppid || status.owner !== Number(env.SUPERCODE_BOARD_GENERATION_OWNER))
268
+ throw new Error('board resting-state admission: status does not name this generation’s own stable owner');
269
+ // Active selected source is authoritative. Recorded/journal history is usable only when the cold owner has no
270
+ // active generation, and every retained source and digest still binds to the actual selection.
271
+ const selected = status.selected ?? installDecisionReader(root, env) ?? recordedReader(status, env);
272
+ const reader = selected ? selectedReader(selected, env) : null;
273
+ // Earlier, unreleased v1 hold readers did not understand the richer Acceptance layout. A hold-mode claim alone
274
+ // cannot authorize a writer of that text; require its separately source-bound codec capability as well.
275
+ let compatible = reader?.protocol.readModes.includes(MODE) === true
276
+ && reader.codec?.formatFeatures?.includes('acceptance-layout-v1') === true;
277
+ const own = restingContract();
278
+ let firstReader = null;
279
+ if (!selected && !compatible && RESTING_WRITE_MODE !== 'legacy-holds' && status.restingReaderProtocol === 1) {
280
+ const ownSelected = { entry: env.SUPERCODE_ORCHESTRATOR_ENTRY, native: env.SUPERCODE_BIN,
281
+ candidate: env.SUPERCODE_SOURCE_CANDIDATE || null, release: env.SUPERCODE_RELEASE_RECEIPT || null };
282
+ const first = { owner: status.owner, generation: env.SUPERCODE_BOARD_GENERATION_ID, selected: ownSelected,
283
+ epoch: status.firstReaderEpoch ?? null };
284
+ if (pristineHome(root, first)) {
285
+ const actual = selectedReader(ownSelected, env);
286
+ if (actual?.codec?.formatFeatures?.includes('acceptance-layout-v1') === true
287
+ && nativeJsonEqual(actual, own)) { firstReader = first; compatible = true; }
288
+ }
289
+ }
290
+ const codec = require('@volter/ztrack/board-document');
291
+ if (typeof codec.inspectBoardRestingSlots !== 'function') throw new Error('board codec has no raw resting-state inspection door');
292
+ admission = Object.freeze({ compatible, selected, reader, inspect: codec.inspectBoardRestingSlots, inspected: new Map() });
293
+ bindRestingReader(compatible);
294
+ let activated = false;
295
+ const preflight = () => {
296
+ if (firstReader && !activated && !pristineHome(root, { ...firstReader, provisional: true }))
297
+ throw new Error('board first-reader admission is no longer pristine; no writer was granted');
298
+ inspectHome(root);
299
+ };
300
+ preflight();
301
+ // Field writers cannot be admitted against a legacy source, even on an empty board. A bridge only publishes the
302
+ // exact tested legacy-readable contract in that mode; rollback over a final reader makes no such claim.
303
+ if (RESTING_WRITE_MODE !== 'legacy-holds' && !compatible)
304
+ throw new Error('board resting-state writer requires an actually selected compatible reader bridge');
305
+ return {
306
+ compatible,
307
+ firstReader: Boolean(firstReader),
308
+ preflight,
309
+ async activateWriter() {
310
+ assertGenerationWriter();
311
+ if (status.restingReaderProtocol === 1) await recordGrantedReader(env);
312
+ else if (RESTING_WRITE_MODE !== 'legacy-holds')
313
+ throw new Error('board owner predates durable resting-state reader selections; restart it through its lifecycle before enabling hold writes');
314
+ activated = true;
315
+ },
316
+ contract: { restingReaderProtocol: 1, resting: own, restingAdmission: firstReader ? 'first-reader' : compatible ? 'compatible-reader' : 'legacy-reader' },
317
+ };
318
+ }