@volter/supercode-orchestrator 0.5.228 → 0.5.229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
package/README.md CHANGED
@@ -19,6 +19,17 @@ stage's own transition, which ends the run in the default workflow. See the
19
19
  [private manual reading](../../docs/evidence/board-manual-claim-2026-10-09.md)
20
20
  for scope and limits; existing inconsistent claims are not repaired automatically.
21
21
 
22
+ The candidate board supports manager-set pause deadlines and an independent
23
+ backlog flag. `workflow pause CARD --reason "why" --until 2h` stops its attempt;
24
+ another pause changes the deadline without ending a second run. Manual resume
25
+ or expiry takes the same `resume` transition and retains its conversation.
26
+ `workflow backlog CARD --reason "why"` accepts settled work for later;
27
+ `workflow plan CARD` returns it to Todo for ordinary capacity admission.
28
+ Backlog blocks every start and holds no execution capacity. Existing homes need
29
+ compatible rules and storage before adoption. The
30
+ [resting-state contract](../../docs/reference/board-resting-states.md) covers
31
+ authority, migration revisions, remaining holds and deliberate replacement.
32
+
22
33
  Completing a task with `--commit` inherits its arc's workspace when the task is
23
34
  scratch. When that workspace belongs to another machine, the board verifies the
24
35
  commit against the declared repository's pushed branch head through GitHub,
@@ -81,7 +92,9 @@ Other SDK callers that persist runtime state set `preserve_workflow: true` on
81
92
  `workflow handover --entry /absolute/path/to/bin/orchestrator.mjs --supercode /absolute/path/to/native --root HOME`
82
93
  prepares a successor through the normal source checks, drains admitted work, and transfers its writer epoch.
83
94
  It routes calls only after a clean round and a successful stats reply. A candidate lost before the handover finishes
84
- rolls back to a fresh generation started from the predecessor's selection; finishing ends the parked predecessor. A request already sent to a lost generation returns an unknown outcome and
95
+ rolls back to a fresh generation started from the predecessor's selection, so the parked predecessor is ended
96
+ (TERM, then KILL) once the successor's first round is clean, and each end is said in the owner's log; one that does not
97
+ exit is named in `board-generation.json` and the owner's status until it does. A request already sent to a lost generation returns an unknown outcome and
85
98
  is never replayed. This source mechanism still needs the complete machine release integration and recorded
86
99
  qualification described in [ADR 0080](../../docs/adr/0080-serving-generation-handovers.md).
87
100
 
@@ -113,7 +126,10 @@ supercode workflow attachment-get <card> <attachment-id> --at sc:<host>:board:<n
113
126
  The npm front door serves `attachment-get`. Without `--at`, use `--root <home>`
114
127
  and optionally `--board <slug>`. `--out` names a local file; its parent directory
115
128
  must exist. A completed download replaces that file. An interrupted download
116
- retains a separate partial file and reports its path; there is no automatic resume.
129
+ keeps every byte it received in a partial file beside `--out` (named for the
130
+ output, card, attachment and version) and reports its path; running the same
131
+ command again continues from that partial's end, as long as the attachment's
132
+ version is unchanged.
117
133
  The board host needs an orchestrator that serves `attachment-read` and its normal
118
134
  running board owner. Remote reads keep the board door's caller and grant checks.
119
135
 
package/activation.mjs CHANGED
@@ -81,7 +81,7 @@ export function declareWorkerHome({ harness, env, cwd, log = () => {} }) {
81
81
  * person's `--add-root` makes, on a root that covers that one folder and nothing under it (`exact_only`: the connector
82
82
  * captures a session only when its recorded folder is that folder, after realpath). A worker's session is recorded in the
83
83
  * folder it was started in, so the exact root captures it, and no subfolder is published by it. Whether a root covers
84
- * the folder is the CLI's own rule (`teams roots --cwd`: rootCovers in roots.mjs, as `agent launch --status` reads it).
84
+ * the folder is the CLI's own rule (`teams roots --cwd`: rootCovers in root-path.mjs, as `agent launch --status` reads it).
85
85
  *
86
86
  * A folder a root already covers (one a person connected by hand, wherever it is) is captured, and nothing more is done.
87
87
  * Otherwise one folder is still every session recorded in it, so only a folder the orchestrator owns is connected: one
@@ -1,3 +1,5 @@
1
+ import { registerWorkOperations, measureWork } from "@volter/supercode-harness-sdk/work-ledger";
2
+ registerWorkOperations([{"kind":"pass","operation":"channel.inbound"},{"kind":"pass","operation":"channel.poll"}]);
1
3
  // A channel carried to an agent's one mailbox (docs/adr/0008-agent-mailbox.md, decisions 15 and 16).
2
4
  //
3
5
  // Any channel adapter (connect(emit) / send(target, content)) is the transport. Each line a person posts is filed as
@@ -64,6 +66,7 @@ export class AgentMailboxBridge {
64
66
 
65
67
  /** One line from the channel, filed as mail to the agent. */
66
68
  async inbound(event) {
69
+ return measureWork("pass", "channel.inbound", async () => {
67
70
  if (event.is_me || !event.message_id) return;
68
71
  const marker = `${this.platform}:${event.message_id}`;
69
72
  const result = await this.client.messageSession({ harness: 'agent', session_id: this.agent }, event.text, {
@@ -87,6 +90,9 @@ export class AgentMailboxBridge {
87
90
  this.threads.set(thread, { chat_id: event.key.chat_id, root: event.key.thread_id ?? event.message_id });
88
91
  this.save();
89
92
  }
93
+
94
+
95
+ });
90
96
  }
91
97
 
92
98
  /** The mailbox marker a line replies to, or none for a root. */
@@ -98,6 +104,7 @@ export class AgentMailboxBridge {
98
104
 
99
105
  /** The agent's answers, filed in this bridge's mailbox, posted in their threads. */
100
106
  async pollAnswers() {
107
+ return measureWork("pass", "channel.poll", async () => {
101
108
  while (this.running) {
102
109
  try {
103
110
  const inbox = await this.client.sessionInbox({ fromName: this.fromName });
@@ -120,5 +127,8 @@ export class AgentMailboxBridge {
120
127
  }
121
128
  await this.sleep(this.pollMs);
122
129
  }
130
+
131
+
132
+ });
123
133
  }
124
134
  }
@@ -1,3 +1,5 @@
1
+ import { registerWorkOperations, measureWork } from "@volter/supercode-harness-sdk/work-ledger";
2
+ registerWorkOperations([{"kind":"door","operation":"gateway.http"}]);
1
3
  // The `api_server`-compatible HTTP surface (ORC-9): Hermes's own client API,
2
4
  // served by this loop, behind the same three-method adapter trait
3
5
  // (`connect(emit)` / `send(target, content)` / `close()`).
@@ -314,6 +316,7 @@ export class ApiServerAdapter {
314
316
  }
315
317
 
316
318
  async route(req, res) {
319
+ return measureWork("door", "gateway.http", async () => {
317
320
  const url = new URL(req.url ?? '/', 'http://localhost');
318
321
  const path = url.pathname;
319
322
  const method = (req.method ?? 'GET').toUpperCase();
@@ -577,6 +580,9 @@ export class ApiServerAdapter {
577
580
  }
578
581
 
579
582
  return send(404, errorBody(`Unknown route: ${method} ${path}`, 'not_found'));
583
+
584
+
585
+ });
580
586
  }
581
587
 
582
588
  /** `_session_response`'s client-safe projection (`api_server.py:4283-4302`). */
package/agents.mjs CHANGED
@@ -1,18 +1,52 @@
1
1
  // Teams identity, orchestrator intent and assignments: one boundary, joined by the immutable agent id.
2
2
  import { DatabaseSync } from 'node:sqlite';
3
- import { mkdirSync, existsSync } from 'node:fs';
3
+ import { mkdirSync, existsSync, statSync } from 'node:fs';
4
4
  import { join, basename } from 'node:path';
5
5
  import { createHash, randomBytes } from 'node:crypto';
6
+ import { setImmediate as yieldToOwner } from 'node:timers/promises';
6
7
  import { runtimeDoor } from './board/runtime-door.mjs';
7
8
  import { resolveAgentProfile } from './agent-profiles.mjs';
8
9
  import { compareAgentProfile } from '@volter/teams/agent-profile';
9
10
  import { WorkspaceContexts } from '@volter/teams/context';
10
11
  import { supercodeHome } from '@volter/teams/home';
12
+ import { listRevision } from './board/listing.mjs';
11
13
  // Agent identity is Teams's (agent-door.mjs, in-process); its intent and assignments are this home's (below).
12
14
  export { agentDoor, agentScope } from './agent-door.mjs';
13
15
  import { agentDoor, agentScope } from './agent-door.mjs';
16
+ // The agent store, held open once per file for the process. A board's delivery pass reads and records every run and
17
+ // card assignment the home ever had (synchronizeAgents, about 600 runs and 400 assignments on volter-org); opening the
18
+ // file for each call, with its schema script, its column check and its cleanup on every open, and closing it (the last
19
+ // close checkpoints and removes the WAL) held the board worker 32229 at about half a core on yuerans-macbook-pro
20
+ // (2026-10-28: synchronizeAgents 56% of its busy time, its opens, schema runs and closes the most of that).
21
+ // Callers keep their open/close shape: the connection handed out ignores close(). Every transaction here is opened and
22
+ // committed or rolled back in one synchronous stretch, so no two callers interleave inside one; a connection left in a
23
+ // transaction by a caller that threw before its rollback is rolled back before it is handed out again. A file replaced
24
+ // or removed underneath (another inode) is opened again.
25
+ const heldStores=new Map();
26
+ function heldStore(path,open,key=path){
27
+ let held=heldStores.get(key),inode=null;
28
+ try{inode=statSync(path).ino;}catch{inode=null;}
29
+ if(held&&(inode===null||held.inode!==inode)){heldStores.delete(key);try{held.db.close();}catch{/* already gone */}held=null;}
30
+ if(!held){
31
+ const db=open();
32
+ const statements=new Map();
33
+ held={db,inode:statSync(path).ino,handle:{
34
+ connection: db,
35
+ prepare:sql=>{let statement=statements.get(sql);if(!statement){statement=db.prepare(sql);statements.set(sql,statement);}return statement;},
36
+ exec:sql=>db.exec(sql),
37
+ close(){/* held for the process */},
38
+ get isTransaction(){return db.isTransaction;},
39
+ }};
40
+ heldStores.set(key,held);
41
+ }
42
+ if(held.db.isTransaction)held.db.exec('ROLLBACK');
43
+ return held.handle;
44
+ }
14
45
  function store(root){
15
- mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'agent-state.sqlite'));
46
+ const path=join(root,'agent-state.sqlite');
47
+ return heldStore(path,()=>{
48
+ mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(path);
49
+ try{
16
50
  db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;
17
51
  CREATE TABLE IF NOT EXISTS agent_intents(org TEXT NOT NULL,id TEXT NOT NULL,key TEXT NOT NULL,profile_ref TEXT NOT NULL,profile_json TEXT NOT NULL,declared INTEGER NOT NULL DEFAULT 0,declaration_json TEXT,created_at INTEGER NOT NULL,PRIMARY KEY(org,id),UNIQUE(org,key));
18
52
  CREATE TABLE IF NOT EXISTS agent_declaration_heads(org TEXT NOT NULL,key TEXT NOT NULL,agent_id TEXT NOT NULL,generation INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(org,key));
@@ -29,8 +63,49 @@ function store(root){
29
63
  const columns=new Set(db.prepare('PRAGMA table_info(agent_runs)').all().map(row=>row.name));
30
64
  if(!columns.has('conversation'))db.exec('ALTER TABLE agent_runs ADD COLUMN conversation INTEGER NOT NULL DEFAULT 0');
31
65
  if(!columns.has('started_at'))db.exec('ALTER TABLE agent_runs ADD COLUMN started_at INTEGER NOT NULL DEFAULT 0');
66
+ db.exec(`CREATE INDEX IF NOT EXISTS agent_runs_session ON agent_runs(session_ref);
67
+ CREATE INDEX IF NOT EXISTS agent_runs_open ON agent_runs(org,session_ref) WHERE ended_at IS NULL;
68
+ CREATE INDEX IF NOT EXISTS agent_runtime_ref ON agent_runtime_sessions(runtime_ref);
69
+ CREATE INDEX IF NOT EXISTS agent_runs_owner ON agent_runs(org,agent_id);
70
+ CREATE INDEX IF NOT EXISTS agent_runs_usage_pending ON agent_runs(org) WHERE usage_json IS NULL;`);
71
+ trackAgentRecords(db);
32
72
  db.prepare('DELETE FROM agent_card_assignment_changes WHERE recorded=1').run();
33
- return db;
73
+ }catch(error){try{db.close();}catch{/* the open failed */}throw error;}
74
+ return db;
75
+ });
76
+ }
77
+ // The delivery pass observes changed ownership/binding facts as well as board runs. No durable
78
+ // queue is added: a restart reseeds from authoritative rows; failed work stays in its pending set.
79
+ const agentRecords = new WeakMap();
80
+ function trackAgentRecords(db) {
81
+ const state = { epoch: 0, floor: 0, sessions: new Map(), targets: new Map() };
82
+ agentRecords.set(db, state);
83
+ db.function('supercode_agent_changed', (kind, key) => {
84
+ if (key == null) return;
85
+ const map = state[kind], epoch = ++state.epoch;
86
+ map.delete(key); map.set(key, epoch);
87
+ if (map.size > 4096) { const first = map.keys().next().value; state.floor = Math.max(state.floor,map.get(first)); map.delete(first); }
88
+ });
89
+ for (const [table,column,kind] of [['agent_runs','session_ref','sessions'], ['agent_runtime_sessions','runtime_ref','sessions'],
90
+ ['agent_assignments','target','targets'], ['agent_card_scope_transitions','target','targets'], ['agent_card_assignment_changes','target','targets']]) {
91
+ for (const [verb,rows] of [['INSERT',['NEW']],['UPDATE',['OLD','NEW']],['DELETE',['OLD']]]) {
92
+ const body = rows.map(row => `SELECT supercode_agent_changed('${kind}',${row}.${column});`).join(' ');
93
+ db.exec(`CREATE TEMP TRIGGER supercode_agent_${table}_${verb.toLowerCase()} AFTER ${verb} ON main.${table} BEGIN ${body} END`);
94
+ }
95
+ }
96
+ for (const [verb,rows] of [['INSERT',['NEW']],['UPDATE',['OLD','NEW']],['DELETE',['OLD']]]) {
97
+ const body = rows.map(row => `SELECT supercode_agent_changed('targets',target) FROM agent_assignments WHERE org=${row}.org AND agent_id=${row}.id;`).join(' ');
98
+ db.exec(`CREATE TEMP TRIGGER supercode_agent_intents_${verb.toLowerCase()} AFTER ${verb} ON main.agent_intents BEGIN ${body} END`);
99
+ }
100
+ }
101
+ /** Changed local agent records, with foreign commits/replaced stores treated as unknown. */
102
+ export function agentRecordChanges(root, previous = null) {
103
+ const db = store(root), connection = db.connection, state = agentRecords.get(connection), revision = listRevision(connection);
104
+ const token = { ...revision, epoch: state.epoch };
105
+ const reset = !previous || previous.connection !== token.connection || previous.data !== token.data
106
+ || previous.mainSchema !== token.mainSchema || previous.tempSchema !== token.tempSchema || previous.epoch < state.floor;
107
+ const changed = map => reset ? null : previous.epoch === state.epoch ? [] : [...map].filter(([,epoch]) => epoch > previous.epoch).map(([key]) => key);
108
+ return { token, reset, sessions: changed(state.sessions), targets: changed(state.targets) };
34
109
  }
35
110
  const digest=value=>createHash('sha256').update(value).digest('hex');
36
111
  const minted=async(key,machine)=>{
@@ -43,12 +118,14 @@ const minted=async(key,machine)=>{
43
118
  };
44
119
  export function agentIntent(root,org,id){const db=store(root);try{const row=db.prepare('SELECT * FROM agent_intents WHERE org=? AND id=?').get(org,id);return row?{...row,profile:JSON.parse(row.profile_json),assignments:db.prepare("SELECT * FROM agent_assignments WHERE org=? AND agent_id=? AND state='active'").all(org,id)}:null;}finally{db.close();}}
45
120
  /** Ownership moves and their board obligation commit together in the assignment owner's store. */
46
- function retainCardAgentChange(db,old,to,profile){
121
+ function retainCardAgentChange(db,old,to,profile,decision=null){
47
122
  if(old?.kind!=='card'||old.agent_id===to)return;
48
123
  const fromProfile=db.prepare('SELECT profile_ref FROM agent_intents WHERE org=? AND id=?').get(old.org,old.agent_id)?.profile_ref??null;
49
- db.prepare('INSERT INTO agent_card_assignment_changes(target,payload) VALUES (?,?)').run(old.target,JSON.stringify({org:old.org,assignment:old.id,role:old.role,from:old.agent_id,to,profile:profile??null,from_profile:fromProfile,changed_at:Date.now()}));
124
+ db.prepare('INSERT INTO agent_card_assignment_changes(target,payload) VALUES (?,?)').run(old.target,JSON.stringify({org:old.org,assignment:old.id,role:old.role,from:old.agent_id,to,profile:profile??null,from_profile:fromProfile,changed_at:Date.now(),...(decision?{decision}: {})}));
50
125
  }
51
- export function assignAgent(root,org,id,{kind,target,role}){
126
+ /** A card role's one assignment, keyed by the card and its role: the only record that names the role's agent. */
127
+ export const cardRoleAssignment=(card,role)=>digest(JSON.stringify(['card',card,role]));
128
+ export function assignAgent(root,org,id,{kind,target,role},{existingAgent=null}={}){
52
129
  const db=store(root),key=digest(JSON.stringify([kind,target,role]));try{
53
130
  db.exec('BEGIN IMMEDIATE');
54
131
  if(kind==='card'&&org==='local'&&db.prepare('SELECT 1 FROM agent_card_scope_transitions WHERE target=?').get(target)){
@@ -56,6 +133,8 @@ export function assignAgent(root,org,id,{kind,target,role}){
56
133
  throw Object.assign(new Error('card agent scope was adopted; local declarations are refused'),{code:'card_scope_adopted'});
57
134
  }
58
135
  const old=db.prepare('SELECT * FROM agent_assignments WHERE org=? AND id=?').get(org,key);
136
+ if(existingAgent&&old?.agent_id!==existingAgent)
137
+ throw Object.assign(new Error(`the established ${role} assignment changed before binding ${existingAgent}`),{continuationRequired:true});
59
138
  db.prepare("INSERT INTO agent_assignments(org,id,agent_id,kind,target,role,state,started_at) VALUES (?,?,?,?,?,?,'active',?) ON CONFLICT(org,id) DO UPDATE SET agent_id=excluded.agent_id,state='active',ended_at=NULL").run(org,key,id,kind,target,role,Date.now());
60
139
  const profile=db.prepare('SELECT profile_ref FROM agent_intents WHERE org=? AND id=?').get(org,id)?.profile_ref;
61
140
  retainCardAgentChange(db,old,id,profile);
@@ -68,20 +147,21 @@ export function cardAgentAssignments(root,target=null){const db=store(root);try{
68
147
  /** Adoption remains authoritative after its delivery receipt is acknowledged. One read pins scope and native state. */
69
148
  export function cardAgentScopeTransition(root,target){
70
149
  const path=join(root,'agent-state.sqlite');if(!existsSync(path))return null;
71
- const db=new DatabaseSync(path,{readOnly:true});
150
+ // read-only, held as the store is (store above); its read transaction ends with each call
151
+ const db=heldStore(path,()=>{const db=new DatabaseSync(path,{readOnly:true});try{db.exec('PRAGMA busy_timeout=5000');}catch(error){db.close();throw error;}return db;},`${path}\0read-only`);
72
152
  try{
73
- db.exec('PRAGMA busy_timeout=5000; BEGIN');
153
+ db.exec('BEGIN');
74
154
  if(!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='agent_card_scope_transitions'").get())return null;
75
155
  const transition=db.prepare('SELECT from_org,to_org AS org,changed_at FROM agent_card_scope_transitions WHERE target=?').get(target);
76
156
  if(!transition)return null;
77
157
  const assignments=db.prepare("SELECT a.*,i.profile_ref AS profile FROM agent_assignments a LEFT JOIN agent_intents i ON i.org=a.org AND i.id=a.agent_id WHERE a.kind='card' AND a.target=? AND a.org=? ORDER BY a.role,a.id").all(target,transition.org);
78
158
  return {...transition,assignments};
79
- }finally{db.close();}
159
+ }finally{if(db.isTransaction)db.exec('COMMIT');}
80
160
  }
81
161
  /** A board consumes these in receipt order before mirroring the assignment's present owner or end. */
82
162
  export function pendingCardAgentChanges(root,target){const db=store(root);try{return db.prepare('SELECT sequence,payload FROM agent_card_assignment_changes WHERE recorded=0 AND target=? ORDER BY sequence').all(target).map(row=>({receipt:Number(row.sequence),...JSON.parse(row.payload)}));}finally{db.close();}}
83
163
  /** The board committed facts and dedupe watermark before acknowledging; only pending receipts remain here. */
84
- export function acknowledgeCardAgentChanges(root,receipts){const db=store(root);try{
164
+ export function acknowledgeCardAgentChanges(root,receipts){if(!receipts.length)return;const db=store(root);try{
85
165
  db.exec('BEGIN IMMEDIATE');
86
166
  for(const receipt of receipts)db.prepare('DELETE FROM agent_card_assignment_changes WHERE sequence=?').run(receipt);
87
167
  db.exec('COMMIT');
@@ -96,7 +176,7 @@ export async function reassignAgent(root,org,assignment,to,{context}={}) {
96
176
  db.exec('BEGIN IMMEDIATE');
97
177
  const moved=db.prepare("UPDATE agent_assignments SET agent_id=? WHERE org=? AND id=? AND agent_id=? AND state='active'").run(to,org,assignment,old.agent_id);
98
178
  if(moved.changes!==1)throw new Error('assignment changed while reassigning it');
99
- retainCardAgentChange(db,old,to,target.profile);
179
+ retainCardAgentChange(db,old,to,target.profile,{kind:'reassign_agent',from:old.agent_id,to});
100
180
  db.prepare('DELETE FROM agent_end_outbox WHERE org=? AND agent_id=?').run(org,to);
101
181
  if(old.agent_id!==to&&!db.prepare("SELECT 1 FROM agent_assignments WHERE org=? AND agent_id=? AND state='active'").get(org,old.agent_id))
102
182
  db.prepare('INSERT OR IGNORE INTO agent_end_outbox(org,agent_id,reason,created_at) VALUES (?,?,?,?)').run(org,old.agent_id,`last assignment reassigned to ${to}`,Date.now());
@@ -160,7 +240,7 @@ export function agentRunForKey(root,org,agent,key) {
160
240
  const db=store(root);try{return db.prepare('SELECT * FROM agent_runs WHERE org=? AND agent_id=? AND run_id=? ORDER BY rowid DESC LIMIT 1').get(org,agent,key)??null;}finally{db.close();}
161
241
  }
162
242
  const declarationHead=(root,org,key)=>{const db=store(root);try{return db.prepare('SELECT agent_id FROM agent_declaration_heads WHERE org=? AND key=?').get(org,key)?.agent_id??null;}finally{db.close();}};
163
- const endedHere=(root,org,id)=>{const db=store(root);try{return !!db.prepare('SELECT 1 FROM agent_runs WHERE org=? AND agent_id=? AND end_reason IS NOT NULL LIMIT 1').get(org,id);}finally{db.close();}};
243
+ export const endedHere=(root,org,id)=>{const db=store(root);try{return !!db.prepare('SELECT 1 FROM agent_runs WHERE org=? AND agent_id=? AND end_reason IS NOT NULL LIMIT 1').get(org,id);}finally{db.close();}};
164
244
  function recordDeclaration(root,org,id,declaration){
165
245
  const db=store(root);try{db.prepare('UPDATE agent_intents SET declaration_json=?,declared=0 WHERE org=? AND id=? AND declaration_json IS NOT ?').run(JSON.stringify(declaration),org,id,JSON.stringify(declaration));}finally{db.close();}
166
246
  }
@@ -173,6 +253,8 @@ async function localAgent(options,{key,name,declaration,assignment,parent=null,s
173
253
  const rootIntent=await declareAgentIntent({root,org,server,profiles,profile:rootProfile,key:'root',machine,refresh:false});
174
254
  recordDeclaration(root,org,rootIntent.id,{kind:'root',profile:rootProfile,name:basename(root),parent:null});
175
255
  let intent=key==='root'?rootIntent:await declareAgentIntent({root,org,server,profiles,profile,key,machine});
256
+ if(options.existingAgent&&(intent.id!==options.existingAgent||endedHere(root,org,intent.id)))
257
+ throw Object.assign(new Error(`established role belongs to ${options.existingAgent}; no successor identity was authorized`), { continuationRequired: true });
176
258
  // An agent ended here is never started again: its end is on its runs here (handOffAgents), and the key's next generation
177
259
  // is minted in its place, with fresh memory.
178
260
  if(key!=='root'&&endedHere(root,org,intent.id))intent=await nextAgentGeneration(root,intent,machine);
@@ -183,7 +265,7 @@ async function localAgent(options,{key,name,declaration,assignment,parent=null,s
183
265
  const identity={id:intent.id,kind:'agent',org,name,path:above?`${above.path}/${name}`:name,parent:above?.id??null,
184
266
  lineage:{starter},profile,state:'active',
185
267
  synced:!!intent.declared,main_session:null,revision:0};
186
- const assigned=assignAgent(root,org,intent.id,assignment);
268
+ const assigned=assignAgent(root,org,intent.id,assignment,{existingAgent:options.existingAgent});
187
269
  return {identity,intended:intent.intended,assignment:assigned,assignments:[assignment],org,synced:false};
188
270
  }
189
271
  export async function localCardAgent(options){
@@ -194,7 +276,17 @@ export async function localCardAgent(options){
194
276
  }
195
277
  // A card role's assignment the manager moved to another agent (`workflow reassign --agent`) runs as that agent: the
196
278
  // active assignment names it, not the key's own head, and an agent whose end was synchronized here is not used.
197
- const held=activeAgentAssignments(root).find(item=>item.org===org&&item.kind==='card'&&item.target===card&&item.role===role);
279
+ const held=activeAgentAssignments(root).find(item=>item.org===org&&item.id===cardRoleAssignment(card,role));
280
+ // Check before localAgent can mint the stable key or advance an ended identity's generation. Reopening a card
281
+ // or observing an ownership mismatch is not a deliberate new-session decision (ADR 0248).
282
+ if(options.existingAgent){
283
+ const head=held?.agent_id??declarationHead(root,org,`${server}/${org}/card/${card}/${role}`);
284
+ // The refusal names the deliberate doors for this failing role (ADR 0248): a reassignment names its role, so the
285
+ // advice never reaches another role's slot.
286
+ const ended=endedHere(root,org,options.existingAgent);
287
+ if(head!==options.existingAgent||ended)
288
+ throw Object.assign(new Error(`established ${role} conversation belongs to ${options.existingAgent}${ended?', which has ended':''}; a successor starts only on a deliberate manager or owner decision (ADR 0248): \`supercode workflow replace-session ${card} --reason "<why>"\` authorizes the role's next agent generation, or \`supercode workflow reassign ${card} --agent AGENT --role ${role}\` hands it to another`), { continuationRequired: true });
289
+ }
198
290
  if(held&&held.agent_id!==declarationHead(root,org,`${server}/${org}/card/${card}/${role}`)&&!endedHere(root,org,held.agent_id)){
199
291
  const record=localAgentRecord(root,org,held.agent_id);
200
292
  const identity=record?{...record.identity,synced:true}:{id:held.agent_id,kind:'agent',org,name:held.agent_id,path:held.agent_id,parent:null,
@@ -208,10 +300,9 @@ export async function localCardAgent(options){
208
300
  starter:options.starterSession?{kind:'session',id:options.starterSession}:{kind:'automation',id:`board:${basename(options.root)}`}});
209
301
  }
210
302
  export async function localProfileAgent(options){
211
- const {root,profiles,profile,agent:declared=null,declarations={}}=options,rootProfile=profiles.default?'default':profile;
303
+ const {root,profiles,profile,agent:declared=null}=options,rootProfile=profiles.default?'default':profile;
212
304
  const starter={kind:'automation',id:`orchestrator:${basename(root)}`};
213
305
  if(declared&&declared!==profile){
214
- const decl=declarations[declared]??{};
215
306
  return localAgent(options,{key:`agent/${declared}`,name:declared,assignment:{kind:'agent',target:declared,role:'declared'},
216
307
  declaration:{kind:'declared',agent:declared,profile},starter});
217
308
  }
@@ -266,30 +357,38 @@ export function homeAgentId(root,{org,server,name,profile=name}){
266
357
  export function localAgentSessions(root,org,id){
267
358
  const db=store(root);try{return db.prepare('SELECT session_ref AS session,machine,ended_at,end_reason FROM agent_runs WHERE org=? AND agent_id=? ORDER BY rowid DESC LIMIT 200').all(org,id);}finally{db.close();}
268
359
  }
269
- /** Retained starter receipts for this home's sessions, including explicit forks.
270
- * Ended owners remain in the join so a live child keeps its original starter. */
271
- export function localSessionDelegations(root, localMachine) {
272
- const db = store(root);
273
- try {
274
- const rows = db.prepare(`SELECT r.*, json_extract(i.declaration_json,'$.starterSession') AS declared_starter
275
- FROM agent_runs r LEFT JOIN agent_intents i ON i.org=r.org AND i.id=r.agent_id`).all();
276
- const addresses = new Map();
277
- const address = row => row.session_ref.startsWith('sc:') ? row.session_ref
278
- : /^(claude-code|codex):[^:]+$/.test(row.session_ref) ? `sc:${row.machine ?? localMachine}:${row.session_ref}` : null;
279
- for (const row of rows) {
280
- const key = `${row.org}|${row.session_ref}`, value = address(row);
281
- if (value) addresses.set(key, value);
360
+ /** Live children and the exact retained starter receipts they name, including ended starters. */
361
+ export async function localSessionDelegations(root, localMachine, keepReading) {
362
+ const address = row => row?.session_ref.startsWith('sc:') ? row.session_ref
363
+ : /^(claude-code|codex):[^:]+$/.test(row?.session_ref) ? `sc:${row.machine ?? localMachine}:${row.session_ref}` : null;
364
+ const edges = [];
365
+ let after = null;
366
+ while (keepReading()) {
367
+ const db = store(root);
368
+ const rows = db.prepare(`SELECT org,session_ref FROM agent_runs WHERE ended_at IS NULL
369
+ ${after ? 'AND (org,session_ref)>(?,?)' : ''} ORDER BY org,session_ref LIMIT 64`).all(...(after ?? []));
370
+ if (!rows.length) break;
371
+ after = [rows.at(-1).org, rows.at(-1).session_ref];
372
+ for (const key of rows) {
373
+ await yieldToOwner();
374
+ if (!keepReading()) return edges;
375
+ const current = store(root);
376
+ const row = current.prepare(`SELECT r.org,r.session_ref,r.machine,r.starter_session,
377
+ json_extract(i.declaration_json,'$.starterSession') AS declared_starter
378
+ FROM agent_runs r LEFT JOIN agent_intents i ON i.org=r.org AND i.id=r.agent_id
379
+ WHERE r.org=? AND r.session_ref=? AND r.ended_at IS NULL`).get(key.org, key.session_ref);
380
+ if (!row) continue;
381
+ const session = address(row), ref = row.starter_session ?? row.declared_starter;
382
+ if (!session || typeof ref !== 'string') continue;
383
+ const alias = ref.startsWith('sc:') ? null : current.prepare(`SELECT r.session_ref,r.machine
384
+ FROM agent_runtime_sessions a JOIN agent_runs r ON r.org=a.org AND r.session_ref=a.session_ref
385
+ WHERE a.org=? AND a.runtime_ref=?`).get(row.org, ref);
386
+ const starter = ref.startsWith('sc:') ? ref : address(alias)
387
+ ?? address(current.prepare('SELECT session_ref,machine FROM agent_runs WHERE org=? AND session_ref=?').get(row.org, ref));
388
+ if (starter) edges.push({ session, starter });
282
389
  }
283
- for (const alias of db.prepare('SELECT * FROM agent_runtime_sessions').all()) {
284
- const value = addresses.get(`${alias.org}|${alias.session_ref}`);
285
- if (value) addresses.set(`${alias.org}|${alias.runtime_ref}`, value);
286
- }
287
- return rows.filter(row => row.ended_at == null).map(row => {
288
- const ref = row.starter_session ?? row.declared_starter;
289
- return { session: address(row), starter: typeof ref === 'string' && ref.startsWith('sc:')
290
- ? ref : addresses.get(`${row.org}|${ref}`) ?? null };
291
- }).filter(edge => edge.session && edge.starter);
292
- } finally { db.close(); }
390
+ }
391
+ return edges;
293
392
  }
294
393
  /** Move identities minted before any Teams context into the Org that is now selected, keeping their ids. */
295
394
  function adoptLocalOrg(root,scope){
@@ -428,7 +527,15 @@ export function recordNativeAgentSession(root,runtimeRef,session) {
428
527
  return agentRunForSession(root,[runtimeRef]);
429
528
  }
430
529
  export function recordAgentRun(root,org,id,{session,machine=null,run,profile=null,main=true,starterSession=null,conversation=false}){
431
- const db=store(root);try{
530
+ const db=store(root);
531
+ // A run already recorded as it is now writes nothing: the delivery pass records every run each pass, and a write
532
+ // transaction for each unchanged one committed hundreds of WAL frames a pass.
533
+ const now=db.prepare('SELECT agent_id,machine,run_id,profile_json,main,starter_session,conversation,started_at FROM agent_runs WHERE org=? AND session_ref=?').get(org,session);
534
+ if(now&&now.agent_id!==id)throw new Error('local session ownership cannot change agent identity');
535
+ const profileJson=profile?JSON.stringify(profile):null;
536
+ if(now&&now.machine===machine&&now.run_id===String(run)&&(profileJson===null||now.profile_json===profileJson)&&now.main===(main?1:0)
537
+ &&!(now.starter_session===null&&starterSession!==null)&&now.conversation===(conversation?1:0)&&now.started_at!==0)return;
538
+ try{
432
539
  db.exec('BEGIN IMMEDIATE');
433
540
  const old=db.prepare('SELECT agent_id FROM agent_runs WHERE org=? AND session_ref=?').get(org,session);
434
541
  if(old&&old.agent_id!==id)throw new Error('local session ownership cannot change agent identity');
@@ -467,6 +574,8 @@ export function recordUsageUnread(root,org,session,stamp){
467
574
  export function runsAwaitingUsage(root,org){
468
575
  const db=store(root);try{return db.prepare('SELECT r.*,u.stamp AS usage_unread FROM agent_runs r LEFT JOIN agent_usage_unread u ON u.org=r.org AND u.session_ref=r.session_ref WHERE r.org=? AND r.usage_json IS NULL ORDER BY u.stamp IS NOT NULL,r.rowid LIMIT 50').all(org);}finally{db.close();}
469
576
  }
577
+ /** Only organizations with outstanding usage, not every organization in historical runs. */
578
+ export function pendingUsageOrgs(root){const db=store(root);try{return db.prepare('SELECT DISTINCT org FROM agent_runs WHERE usage_json IS NULL').all().map(row=>row.org);}finally{db.close();}}
470
579
  /** Usage records per agent id: every session this home ran for it. */
471
580
  export function agentUsage(root){
472
581
  const db=store(root),by=new Map();
package/apply/doors.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
2
+ const spawnSync = (...args) => measureSyncChild(() => workLedgerNativeSpawnSync(...args));
1
3
  // The applier's doors (docs/plans/content-spec-status.md §7 "The door"): every change goes through
2
4
  // the harness's own functions, never its files. One shape per gateway:
3
5
  //
@@ -13,7 +15,7 @@
13
15
  // or inside a container. The orchestrator: its operator verbs, through the running daemon's socket when it
14
16
  // is up and the package's cold path when it is not; its folder is read through the package's own `load`.
15
17
 
16
- import { spawnSync } from 'node:child_process';
18
+ import { spawnSync as workLedgerNativeSpawnSync } from 'node:child_process';
17
19
  import { existsSync, readFileSync } from 'node:fs';
18
20
  import { createConnection } from 'node:net';
19
21
  import { dirname, join, resolve } from 'node:path';
@@ -1,3 +1,4 @@
1
+ import { registerWorkService, closeWorkLedger } from '@volter/supercode-harness-sdk/work-ledger';
1
2
  // `supercode-orchestrator agent-channel --agent <name> --platform slack|jira`: a channel carried to an agent's one mailbox
2
3
  // (docs/adr/0008-agent-mailbox.md) through the mailbox bridge (company RFC 0026 decision 3: an engagement's Slack
3
4
  // channel and its client's ticket comments are channels of its account manager):
@@ -10,6 +11,7 @@
10
11
  import { parseArgs } from 'node:util';
11
12
  import { homedir } from 'node:os';
12
13
  import { join } from 'node:path';
14
+ import { supercodeHome } from '@volter/teams/home';
13
15
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
14
16
  import { AgentMailboxBridge } from '../adapters/agent-mailbox.mjs';
15
17
  import { JiraCommentsAdapter } from '../adapters/jira-comments.mjs';
@@ -23,8 +25,11 @@ if (values.help || !values.agent || !values.platform) {
23
25
  console.log('supercode-orchestrator agent-channel --agent <name> --platform slack --slack-channel <id>[,<id>] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform jira --site <url> --jql <jql> [--state <file>]');
24
26
  process.exit(values.help ? 0 : 2);
25
27
  }
28
+ registerWorkService({role:'agent-channel',scope:{kind:'install',id:`${values.agent}\0${values.platform}\0${process.env.SUPERCODE_ORCHESTRATOR_HOME ?? process.env.HERMES_HOME ?? homedir()}`}});
26
29
  const log = (level, line) => (level === 'debug' ? undefined : console.error(`${new Date().toISOString()} ${level} ${line}`));
27
- const statePath = (name) => values.state ?? join(homedir(), '.config', 'supercode', 'agent-channels', `${values.agent}.${name}.json`);
30
+ // under this install's supercode home (SUPERCODE_HOME, else XDG_CONFIG_HOME/supercode, else ~/.config/supercode), as every
31
+ // other supercode state is: a daemon run with its own home keeps its channels there
32
+ const statePath = (name) => values.state ?? join(supercodeHome(), 'agent-channels', `${values.agent}.${name}.json`);
28
33
  let adapter;
29
34
  if (values.platform === 'jira') {
30
35
  if (!values.site || !values.jql) { console.error('agent-channel: --platform jira needs --site and --jql'); process.exit(2); }
@@ -50,6 +55,6 @@ const client = new SupercodeHarnessClient(process.env.SUPERCODE_BIN ? { command:
50
55
  const bridge = new AgentMailboxBridge({ adapter, client, agent: values.agent, platform: values.platform, fromName: `${values.platform}-${values.agent}`, log, statePath: statePath(values.platform) });
51
56
  await bridge.start();
52
57
  log('info', `${values.agent}: ${values.platform} is carried to its mailbox`);
53
- const stop = async () => { await bridge.stop(); await adapter.close?.(); await client.close?.(); process.exit(0); };
58
+ const stop = async () => { await bridge.stop(); await adapter.close?.(); await client.close?.(); await closeWorkLedger(); process.exit(0); };
54
59
  process.on('SIGTERM', stop);
55
60
  process.on('SIGINT', stop);