@volter/supercode-orchestrator 0.5.210 → 0.5.212

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.
@@ -38,7 +38,7 @@ CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_pub
38
38
  // Columns the publication tables gained after their first layout; store.mjs layOut adds each one a store lacks. A head's
39
39
  // fingerprint is the cheap reading of its card's rows the head was last compared at (fingerprintOf); the log's prune_from
40
40
  // is where a prune under way resumes, null when none is (prunePublications).
41
- export const PUBLICATION_COLUMNS = { supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
41
+ export const PUBLICATION_COLUMNS = { supercode_publications: { snapshot_bytes: 'INTEGER' }, supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
42
42
 
43
43
  // The snapshot's shape: 1 names the card as a person does (`ref`, VO-856).
44
44
  const SNAPSHOT_SHAPE = 4;
@@ -56,7 +56,7 @@ const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comm
56
56
  export function publicationTriggers(db) {
57
57
  const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
58
58
  const first = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_publish_tasks_insert'").get();
59
- const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST].map((kind) => `'${kind}'`).join(',')})`;
59
+ const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
60
60
  const agentMigration = tables.has('task_events') && !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'agent_assignment_adopted'");
61
61
  const scopeMigration = !db.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_supercode_card_agent_scopes_insert'").get();
62
62
  // A snapshot names its card's number: a store that numbers its cards republishes every card once, when that is watched.
@@ -64,11 +64,12 @@ export function publicationTriggers(db) {
64
64
  // A snapshot of a newer shape (SNAPSHOT_SHAPE) than the heads were published in: every card is published once again.
65
65
  const shapeMigration = Number(db.prepare('SELECT max(version) AS version FROM supercode_publication_shape').get()?.version ?? 0) < SNAPSHOT_SHAPE;
66
66
  const contentMigration = !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_comments_update'").get()?.sql.includes('fingerprint=NULL');
67
+ const archiveMigration = !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'archived'");
67
68
  db.exec('SAVEPOINT supercode_publication_marking');
68
69
  try {
69
70
  // Existing event triggers only watched reviews. Replace that filter once, preserving the savepoint rollback.
70
71
  if(contentMigration)for(const table of ['task_comments','task_events'])for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_${table}_${verb}`);
71
- if(agentMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
72
+ if(agentMigration||archiveMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
72
73
  for (const [table, columns] of MARKED) {
73
74
  if (!tables.has(table)) continue;
74
75
  // Reviews and agent assignment events change a snapshot: an update marks when the
@@ -80,7 +81,7 @@ export function publicationTriggers(db) {
80
81
  CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_update AFTER UPDATE ON ${table}${when(['OLD', 'NEW'])} BEGIN ${mark('NEW')} ${mark('OLD')} END;
81
82
  CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_delete AFTER DELETE ON ${table}${when(['OLD'])} BEGIN ${mark('OLD')} END;`);
82
83
  }
83
- if ((first || contentMigration || agentMigration || scopeMigration || numberMigration || shapeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
84
+ if ((first || contentMigration || archiveMigration || agentMigration || scopeMigration || numberMigration || shapeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
84
85
  SELECT id FROM tasks UNION SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed'
85
86
  EXCEPT SELECT task_id FROM supercode_publication_dirty`);
86
87
  if (shapeMigration) { db.exec('DELETE FROM supercode_publication_shape'); db.prepare('INSERT INTO supercode_publication_shape (version) VALUES (?)').run(SNAPSHOT_SHAPE); }
@@ -100,8 +101,8 @@ export function markRemovedHeads(db) {
100
101
  const RETAINED_CHANGES = 1024;
101
102
  // The changes one prune transaction deletes: a change's snapshot averaged about 190 KB and reached 3.7 MB, and deleting
102
103
  // every pruned change in one statement held the owner's event loop for 291 s (2026-10-21).
103
- const PRUNE_BATCH = 200;
104
- const PRUNE_BYTES = 8 * 1024 * 1024;
104
+ const PRUNE_BATCH = 16;
105
+ const PRUNE_BYTES = 1024 * 1024;
105
106
  const hash = value => createHash('sha256').update(value).digest('hex');
106
107
  const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
107
108
  const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
@@ -206,13 +207,10 @@ const perCard=db=>{
206
207
  };
207
208
  // The columns of a task row that change while its card runs and that no snapshot reads (cardSnapshot).
208
209
  const UNREAD_TASK_COLUMNS = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at']);
209
- // The events a snapshot reads: its reviews, its agents and its archive reason.
210
- const SNAPSHOT_EVENT_KINDS = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
211
210
  const fingerprintReads=db=>({
212
- // comments and events are only ever added or removed by supercode's writers; their bytes catch a document edit that
213
- // rewrites one in place; SQLite answers octet_length from a row's header, without reading its text
214
- comments:db.prepare('SELECT count(*) AS n,max(id) AS last,total(octet_length(body)) AS bytes FROM task_comments WHERE task_id=?'),
215
- events:db.prepare(`SELECT count(*) AS n,max(id) AS last,total(octet_length(payload)) AS bytes FROM task_events WHERE task_id=? AND kind IN ${SNAPSHOT_EVENT_KINDS}`),
211
+ // Comment/event inserts, updates and deletes invalidate the head's fingerprint in their native transaction.
212
+ // Even a same-length in-place edit forces a snapshot comparison; rereading every history row's header here adds
213
+ // no information and held the owner's loop for 1.9 s on one card's comment history.
216
214
  facts:db.prepare('SELECT facts_json,receipt_sequence,declared_event_id FROM supercode_card_agent_facts WHERE task_id=?'),
217
215
  scope:db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?'),
218
216
  // a run's metadata changes in place (its session's observations), so every run's snapshot columns are read whole
@@ -227,7 +225,7 @@ const fingerprintReads=db=>({
227
225
  function fingerprintOf(reads,rows,row,key,options){
228
226
  const task={};for(const [column,value] of Object.entries(row))if(!UNREAD_TASK_COLUMNS.has(column))task[column]=value;
229
227
  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),
230
- reads.comments.get(row.id),reads.events.get(row.id),reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
228
+ reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
231
229
  }
232
230
  /** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
233
231
  * (`VO-856`, store.mjs cardRef), null for a card with no number. */
@@ -270,7 +268,7 @@ export function recordCardPublications(db,actor,root,key) {
270
268
  db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
271
269
  const rows=perCard(db),reads=fingerprintReads(db),task=db.prepare('SELECT * FROM tasks WHERE id=?'),head=db.prepare('SELECT resource_id,digest,last_event_id,fingerprint FROM supercode_publication_heads WHERE resource_id=?');
272
270
  const lastEvent=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1');
273
- const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)');
271
+ const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at,snapshot_bytes) VALUES (?,?,?,?,?,?,?)');
274
272
  const upsert=db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id,fingerprint) VALUES (?,?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id,fingerprint=excluded.fingerprint');
275
273
  const compared=db.prepare('UPDATE supercode_publication_heads SET fingerprint=? WHERE resource_id=?');
276
274
  let changed=0;
@@ -285,10 +283,11 @@ export function recordCardPublications(db,actor,root,key) {
285
283
  if(previous?.digest===digest){compared.run(`${fingerprint}:${digest}`,id);continue;}
286
284
  const last=lastEvent.get(id);
287
285
  const kind=`task.${previous&&previous.digest!=='removed'?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
288
- const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now());
286
+ const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
289
287
  upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null,`${fingerprint}:${digest}`);changed++;
290
288
  }else if(previous&&previous.digest!=='removed'){
291
- const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',JSON.stringify({id,removed:true}),actor,Date.now());
289
+ const snapshot=JSON.stringify({id,removed:true});
290
+ const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
292
291
  db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=?,fingerprint=NULL WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),id);changed++;
293
292
  }
294
293
  }
@@ -330,7 +329,9 @@ export function prunePublications(db) {
330
329
  from=0;
331
330
  }
332
331
  const floor=Number(db.prepare('SELECT pruned_through FROM supercode_publication_log').get().pruned_through);
333
- const candidates=db.prepare(`SELECT sequence,octet_length(snapshot) AS bytes FROM supercode_publications p WHERE sequence>? AND sequence<=? AND ${OLDER} ORDER BY sequence LIMIT ?`).all(from,floor,PRUNE_BATCH);
332
+ // New rows carry their byte count. Legacy rows fall back one small page at a time; never backfill old snapshots
333
+ // during an ordinary verb or scan hundreds of snapshot headers before the byte budget can be applied.
334
+ const candidates=db.prepare(`SELECT sequence,COALESCE(snapshot_bytes,octet_length(snapshot)) AS bytes FROM supercode_publications p WHERE sequence>? AND sequence<=? AND ${OLDER} ORDER BY sequence LIMIT ?`).all(from,floor,PRUNE_BATCH);
334
335
  const batch=[];let bytes=0;
335
336
  for(const row of candidates){
336
337
  // A single oversized row still makes progress, in a transaction of its own.
@@ -0,0 +1,30 @@
1
+ // Local reads whose cost must follow the matching keys, not the board's accumulated history.
2
+ import { setImmediate as yieldToOwner } from 'node:timers/promises';
3
+
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));
9
+ }
10
+
11
+ /** Read run history in finite, indexed pages, yielding before each page. The candidate page precedes any JSON
12
+ * predicate, so a page that matches nothing still reads at most 32 rows. Mutations must re-read their target. */
13
+ export async function readRunPages(db, columns, { task = null, where = '1' } = {}) {
14
+ const high = Number(db.prepare('SELECT MAX(id) AS id FROM task_runs').get()?.id ?? 0);
15
+ const out = [];
16
+ let cursor = 0;
17
+ while (cursor < high) {
18
+ await yieldToOwner();
19
+ const ids = db.prepare(`SELECT id FROM task_runs WHERE id>? AND id<=?${task ? ' AND task_id=?' : ''} ORDER BY id LIMIT 32`)
20
+ .all(cursor, high, ...(task ? [task] : []));
21
+ if (!ids.length) break;
22
+ cursor = ids.at(-1).id;
23
+ out.push(...db.prepare(`SELECT ${columns} FROM task_runs WHERE id IN (SELECT value FROM json_each(?)) AND (${where}) ORDER BY id`)
24
+ .all(JSON.stringify(ids.map(row => row.id))));
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** Unended runs plus ended runs a card still names current. Both arms start from small indexed sets. */
30
+ export const ACTIVE_RUN_IDS = 'SELECT id FROM task_runs WHERE ended_at IS NULL UNION SELECT current_run_id FROM tasks WHERE current_run_id IS NOT NULL';
package/board/store.mjs CHANGED
@@ -221,6 +221,11 @@ export function listBoards(root) {
221
221
  // SQLite uses an expression index only for a statement that writes the expression the same way.
222
222
  const RUN_SESSION = "json_extract(metadata, '$.supercode.session_id')";
223
223
  const RUN_ADDRESS = "json_extract(metadata, '$.supercode.address')";
224
+ // Notification records historically skipped malformed metadata through the JS parser. Keep that behavior in both
225
+ // the lookup and its indexes, including a legacy store that cannot create the older unguarded JSON indexes.
226
+ const NOTICE_SESSION = `CASE WHEN json_valid(metadata,6) THEN ${RUN_SESSION} END`;
227
+ const NOTICE_ADDRESS = `CASE WHEN json_valid(metadata,6) THEN ${RUN_ADDRESS} END`;
228
+ export const NOTICE_RUN_IDS = `SELECT id FROM task_runs WHERE ${NOTICE_SESSION}=? UNION SELECT id FROM task_runs WHERE ${NOTICE_ADDRESS}=?`;
224
229
  const RUN_LAUNCH_PENDING = "json_extract(metadata, '$.supercode.launch_pending')";
225
230
  export const CALLER_RUN_ADDRESS = `SELECT ${RUN_ADDRESS} AS address FROM task_runs WHERE ${RUN_SESSION} = ? ORDER BY id DESC LIMIT 1`;
226
231
  // Launch/adoption commits the native address and its immutable role-agent together.
@@ -242,6 +247,10 @@ const RUN_LAUNCH_PENDING_INDEX = 'idx_runs_supercode_launch_pending';
242
247
  const RUN_INDEXES = [
243
248
  [RUN_SESSION_INDEX, `(${RUN_SESSION}, id, ${RUN_ADDRESS})`],
244
249
  [RUN_LAUNCH_PENDING_INDEX, `(id) WHERE ${RUN_LAUNCH_PENDING} = 1`],
250
+ ['idx_runs_notice_session', `(${NOTICE_SESSION}, id)`],
251
+ ['idx_runs_notice_address', `(${NOTICE_ADDRESS}, id)`],
252
+ ['idx_runs_open_machine_session', `(json_extract(metadata, '$.supercode.machine'), ${RUN_SESSION}, id) WHERE ended_at IS NULL`],
253
+ ['idx_runs_open_machine_pane', `(json_extract(metadata, '$.supercode.machine'), json_extract(metadata, '$.supercode.pane'), id) WHERE ended_at IS NULL`],
245
254
  ];
246
255
 
247
256
  // What every verb a session runs asks about its caller (cli.mjs ownCards, dispatch.mjs reconcileCallerLaunch). Each
@@ -395,6 +404,13 @@ export function layOut(db, { fresh }) {
395
404
  // each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
396
405
  // any statement is prepared; no statement names it, so a store without it is only slower.
397
406
  db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
407
+ // Current-run joins and unended-run reconciliation must never search all cards/runs for each historical run.
408
+ db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_current_run ON tasks(current_run_id) WHERE current_run_id IS NOT NULL');
409
+ db.exec('CREATE INDEX IF NOT EXISTS idx_runs_unended ON task_runs(id) WHERE ended_at IS NULL');
410
+ // Ordinary comments vastly outnumber explicit source declarations. Keep their JSON discriminator in a small index.
411
+ 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'");
412
+ // Capacity pages need these columns without visiting task body/result overflow pages.
413
+ db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_capacity ON tasks(id,status,assignee,current_run_id,model_override)');
398
414
  layOutNumbers(db);
399
415
  layOutEventIndex(db);
400
416
  layOutHistoryIndexes(db);
@@ -500,7 +516,7 @@ export function heldConfigWatched(slug, source, watched) {
500
516
 
501
517
  export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
502
518
  // a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
503
- if (current().readOnly) return openReadOnly(root, slug);
519
+ if (current().readOnly) return openBoardSnapshot(root, slug);
504
520
  if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
505
521
  let entry = heldBoards.get(slug);
506
522
  if (!entry) {
@@ -537,7 +553,7 @@ function heldHandle(entry, slug) {
537
553
  * reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
538
554
  * (listing.mjs byOwner).
539
555
  */
540
- function openReadOnly(root, slug) {
556
+ export function openBoardSnapshot(root, slug) {
541
557
  const use = backingOf(root, slug);
542
558
  if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
543
559
  const path = backingPaths(root, slug)[use];
@@ -20,6 +20,7 @@ import { evaluate, holds, render, needsSources } from './expr.mjs';
20
20
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
21
21
  import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
22
22
  import { cardNumber, identifyNotice, numbered } from './notices.mjs';
23
+ import { latestEvent } from './queries.mjs';
23
24
 
24
25
  const loaded = new Map();
25
26
  // the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
@@ -189,7 +190,7 @@ export function completionOwnerOf(db, id) {
189
190
 
190
191
  /** A card's explicitly named review profile, retained in its own history. */
191
192
  export function reviewerOf(db, id) {
192
- return json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind IN ('created','reviewer_set') ORDER BY id DESC LIMIT 1", id)?.payload, {})?.reviewer ?? null;
193
+ return json(latestEvent(db, id, ['created', 'reviewer_set'])?.payload, {})?.reviewer ?? null;
193
194
  }
194
195
 
195
196
  export function latestReview(db, id) {
@@ -381,6 +382,14 @@ function stateRequestOf(db, id) {
381
382
 
382
383
  const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
383
384
  const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
385
+ /** Card bodies use Markdown headings and standalone labels such as `Acceptance:` and `Tasks:`. Every label ends
386
+ * the preceding section, so a task or another section's checkbox never acquires Acceptance permissions. */
387
+ export function cardSectionHeading(line) {
388
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
389
+ if (heading) return heading[1].replace(/:$/, '').toLowerCase();
390
+ const label = /^\s*([A-Za-z][A-Za-z0-9 _-]*):\s*$/.exec(line);
391
+ return label ? label[1].trim().toLowerCase() : null;
392
+ }
384
393
  /** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
385
394
  * and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
386
395
  * needs, added after it); no other line added, removed or changed. */
@@ -392,8 +401,8 @@ function onlyTicks(was, body) {
392
401
  // an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
393
402
  let section = null;
394
403
  return before.every((line, i) => {
395
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
396
- if (heading) section = heading[1].toLowerCase();
404
+ const heading = cardSectionHeading(line);
405
+ if (heading !== null) section = heading;
397
406
  if (line === after[i]) return true;
398
407
  if (section !== 'acceptance') return false;
399
408
  const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
@@ -469,7 +478,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
469
478
  get latest_review() { return latestReview(db, task.id) ?? {}; },
470
479
  get review_candidate() { return reviewCandidate(board, task.id) ?? {}; },
471
480
  ci: completionEvidence(board, task.id),
472
- get dispatch_hold() { return one(db, "SELECT kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1", task.id)?.kind === 'dispatch_held'; },
481
+ get dispatch_hold() { return latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'kind')?.kind === 'dispatch_held'; },
473
482
  status,
474
483
  blocked_by: blocks,
475
484
  paused: json(state.paused, null),
@@ -486,8 +495,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
486
495
  const lines = String(task.body ?? '').split('\n');
487
496
  let acceptance = false;
488
497
  return lines.every((line) => {
489
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
490
- if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
498
+ const heading = cardSectionHeading(line);
499
+ if (heading !== null) acceptance = heading === 'acceptance';
491
500
  if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
492
501
  return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
493
502
  });
@@ -1054,7 +1063,7 @@ const ACTIONS = {
1054
1063
  else runSession(ctx, (s) => ({ ...s, [counter]: 0 }));
1055
1064
  }
1056
1065
  },
1057
- end_run(ctx, { outcome, summary = null, error = null } = {}) {
1066
+ end_run(ctx, { outcome, summary = null, error = null, close_session = true } = {}) {
1058
1067
  const row = one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id);
1059
1068
  const t = now();
1060
1069
  if (row?.current_run_id) {
@@ -1062,10 +1071,15 @@ const ACTIONS = {
1062
1071
  retireSupersededRuns(ctx.db, ctx.id, runId, json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode);
1063
1072
  run(ctx.db, `UPDATE task_runs SET status = ?, outcome = ?, summary = COALESCE(?, summary), error = COALESCE(?, error), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
1064
1073
  WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
1065
- requestSessionClose(ctx.db, ctx.id, { runId });
1066
1074
  ctx.runId = runId;
1067
1075
  ctx.lastOutcome = outcome;
1068
- ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
1076
+ runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
1077
+ // A declared completion may end the attempt while retaining its role session. In particular, its own caller
1078
+ // must be able to finish and return before the board starts the independent review.
1079
+ if (close_session) {
1080
+ requestSessionClose(ctx.db, ctx.id, { runId });
1081
+ ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
1082
+ }
1069
1083
  } else if (summary || ctx.scope.event?.metadata) {
1070
1084
  // an outcome reached with no run open (a person completing a card nobody claimed)
1071
1085
  const task = one(ctx.db, 'SELECT assignee FROM tasks WHERE id = ?', ctx.id);
@@ -1140,13 +1154,24 @@ const ACTIONS = {
1140
1154
  const task = cardOf(ctx.db, ctx.id);
1141
1155
  const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
1142
1156
  run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
1157
+ if (readable !== task.readable_id) {
1158
+ // Keep the obligation with the edit: a stopped dispatcher or interrupted caller must not lose the rename.
1159
+ // Delivery reads the current name, so an older edit can never restore an obsolete name.
1160
+ const effect = { kind: 'rename_card_agents', id: ctx.id };
1161
+ const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
1162
+ ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
1163
+ }
1143
1164
  }
1144
1165
  if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
1145
1166
  if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
1146
1167
  },
1147
1168
  decompose(ctx, said) {
1148
- // the children are filed by the caller before the event (they are cards of their own); the card waits on them
1149
- for (const child of said?.children ?? []) run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
1169
+ // Native arc tasks are worked by the arc's session, never dispatched as prerequisite cards of their own.
1170
+ // Retain the older workflow's dependency action only for separately declared cards.
1171
+ for (const child of said?.children ?? []) {
1172
+ if (one(ctx.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id=?', child)?.subtask_of === ctx.id) continue;
1173
+ run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
1174
+ }
1150
1175
  },
1151
1176
  // card ids a handoff names that no card has (Hermes flags them the same way)
1152
1177
  check_references(ctx, said) {
@@ -1231,11 +1256,12 @@ const ACTIONS = {
1231
1256
  /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
1232
1257
  export function retireSupersededRuns(db, id, runId, session) {
1233
1258
  if (!session?.session_id && !session?.pane) return;
1234
- for (const old of all(db, `SELECT id, task_id, metadata FROM task_runs WHERE id < ? AND ended_at IS NULL
1235
- AND json_extract(metadata, '$.supercode.machine') IS ?
1236
- AND ((? IS NOT NULL AND json_extract(metadata, '$.supercode.session_id') = ?)
1237
- OR (? IS NOT NULL AND json_extract(metadata, '$.supercode.pane') = ?))`,
1238
- runId, session.machine ?? null, session.session_id ?? null, session.session_id ?? null, session.pane ?? null, session.pane ?? null)) {
1259
+ for (const old of all(db, `SELECT id,task_id,metadata FROM task_runs WHERE id IN (
1260
+ SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1261
+ AND json_extract(metadata, '$.supercode.session_id')=?
1262
+ UNION SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1263
+ AND json_extract(metadata, '$.supercode.pane')=?)`,
1264
+ runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
1239
1265
  const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
1240
1266
  if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
1241
1267
  if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
@@ -1,5 +1,5 @@
1
1
  // Filesystem/RPC work happens before a board transaction; the ownership check is pure.
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
3
  import { basename, dirname } from 'node:path';
4
4
  import { runtimeDoor } from './runtime-door.mjs';
5
5
  import { inspectCheckout } from '@volter/teams/checkout';
@@ -130,23 +130,34 @@ export async function checkWorkspace(board, task) {
130
130
  if (task.workspace_kind === 'scratch') return null;
131
131
  task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
132
132
  const facts = await resolveWorkspace(task), peers = [];
133
- if (task.workspace_kind === 'dir' && !facts.linked) {
134
- for (const row of board.db.prepare(`SELECT t.id,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='dir'`).all()) {
133
+ const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
134
+ // New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
135
+ // once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
136
+ {
137
+ for (const row of board.db.prepare(`SELECT t.id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
135
138
  if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
136
139
  let other;
137
- try { other = await resolveWorkspace({ ...row, workspace_kind: 'dir' }); } catch { continue; } // That card's own launch reports its invalid workspace.
138
- if (!other.linked && other.root === facts.root) peers.push(row.id);
140
+ try { other = await resolveWorkspace({ ...row, handle: cardHandleOf(board, row.id) }); } catch { continue; } // Its retained path/reservation is still checked below.
141
+ if ((row.workspace_kind === 'worktree' ? other.path : other.root) === workspaceRoot) peers.push(row.id);
139
142
  }
140
143
  }
141
- const checked = { ...facts, mainRoot: task.workspace_kind === 'dir' && !facts.linked ? facts.root : null, peers };
144
+ const checked = { ...facts, workspaceRoot, peers };
142
145
  assertWorkspaceOwner(board.db, task, checked);
143
146
  return checked;
144
147
  }
148
+ const workspacePath = (path, local) => {
149
+ if (!path) return null;
150
+ if (local) { try { return realpathSync(path); } catch { /* a pre-launch reservation may not exist yet */ } }
151
+ return path.replace(/\/+$/, '');
152
+ };
145
153
  export function assertWorkspaceOwner(db, task, checked) {
146
- if (!checked?.mainRoot) return;
147
- const conflict = db.prepare(`SELECT t.id,c.machine,c.workspace_root FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='dir'`).all()
148
- .find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && (row.workspace_root === checked.mainRoot || checked.peers?.includes(row.id)));
149
- if (conflict) throw new Error(`main checkout is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
154
+ if (!checked?.workspaceRoot) return;
155
+ const machine = normalMachine(task.machine ?? localMachine()), local = machine === localMachine();
156
+ const path = workspacePath(checked.workspaceRoot, local);
157
+ const conflict = db.prepare(`SELECT t.id,t.workspace_path,c.machine,c.workspace_root FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch'`).all()
158
+ .find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === machine &&
159
+ (workspacePath(row.workspace_root, local) === path || workspacePath(row.workspace_path, local) === path || checked.peers?.includes(row.id)));
160
+ if (conflict) throw new Error(`workspace ${checked.path} is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
150
161
  }
151
162
 
152
163
  export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();
package/board-input.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  /** Read a remote card write's selected text file (or stdin) on the caller's machine. */
2
2
  export function prepareBoardText(argv: string[]): { argv: string[]; stdin: string | null };
3
+ /** Read a caller-local attachment in bounded requests, ending with its digest. */
4
+ export function prepareBoardInputs(argv: string[]): Generator<{ argv: string[]; stdin: string | null }>;
package/board-input.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // A remote card write reads its text on the caller's machine, before the board door is called.
2
2
  // The board's own argument parser decides which file is used; its last value wins.
3
- import { readFileSync } from 'node:fs';
3
+ import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { basename } from 'node:path';
4
6
  import { parse } from './board/args.mjs';
5
7
 
6
8
  /** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
@@ -14,3 +16,33 @@ export function prepareBoardText(argv) {
14
16
  // reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
15
17
  return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
16
18
  }
19
+
20
+ /** Each yielded request fits one board frame. Only the last answer is the attachment receipt. */
21
+ export function* prepareBoardInputs(argv) {
22
+ const args = parse(argv);
23
+ if (args._[0] !== 'attach' || args.help) { yield prepareBoardText(argv); return; }
24
+ if (args._.length !== 3 || args.upload !== undefined) throw new Error('attach needs a card id and a local file');
25
+ const source = openSync(args._[2], 'r');
26
+ try {
27
+ const before = fstatSync(source, { bigint: true });
28
+ if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('attach needs a regular file of supported size');
29
+ const upload = randomUUID(), size = Number(before.size), hash = createHash('sha256');
30
+ const command = ['attach', args._[1], '-', '--upload', upload];
31
+ if (args.json) command.push('--json');
32
+ for (const key of ['name', 'content-type', 'author']) if (args[key] !== undefined) command.push(`--${key}`, args[key]);
33
+ const input = body => ({ argv: command, stdin: JSON.stringify(body) });
34
+ yield input({ action: 'begin', size, name: args.name ?? basename(args._[2]) });
35
+ const buffer = Buffer.alloc(1024 * 1024);
36
+ for (let offset = 0; offset < size;) {
37
+ const count = readSync(source, buffer, 0, Math.min(buffer.length, size - offset), offset);
38
+ if (count <= 0) throw new Error('attachment ended during upload; no attachment was committed');
39
+ const bytes = buffer.subarray(0, count); hash.update(bytes);
40
+ yield input({ action: 'append', offset, bytes: bytes.toString('base64') });
41
+ offset += count;
42
+ }
43
+ const after = fstatSync(source, { bigint: true });
44
+ if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
45
+ throw new Error('attachment changed during upload; no attachment was committed');
46
+ yield input({ action: 'commit', sha256: hash.digest('hex') });
47
+ } finally { closeSync(source); }
48
+ }
package/hermes-cli.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  // Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
2
2
  // skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
3
3
  // against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
4
- // pinned `hermes` on its `PATH`; and what Hermes's scheduler records about a failed run (its incidents,
5
- // in `cron/executions.db`) the orchestrator records through Hermes's own function.
4
+ // pinned `hermes` on its `PATH`. Native cron incidents use the owning interchange codec, in the same executions store.
6
5
  import { spawn, spawnSync } from 'node:child_process';
7
6
  import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
8
7
  import { delimiter, dirname, join } from 'node:path';
9
8
  import { parseDotenv } from './orchestration.mjs';
10
9
  import { locateHermes } from './apply/doors.mjs';
10
+ import { recordCronIncident } from '@volter/supercode-interchange/cron-incidents';
11
+ import { redactText } from '@volter/teams/redact';
11
12
 
12
13
  /** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
13
14
  function python(install, code, payload, env, timeout) {
@@ -48,23 +49,10 @@ export function hermesToolEnv(profileDir) {
48
49
  return env;
49
50
  }
50
51
 
51
- /**
52
- * Record a failed run as Hermes's scheduler does: `cron.incidents.upsert_incident`, one incident per job
53
- * and error signature, in the profile's `cron/executions.db`. Answers the incident id, or null (and why)
54
- * when Hermes is not installed or refused.
55
- */
56
- export function recordIncident(profileDir, { job_id, job_name = null, error }) {
57
- const install = hermesInstall();
58
- if (!install?.python) return { id: null, why: 'no Hermes install on PATH' };
59
- const code = 'import json,sys\nfrom cron.incidents import upsert_incident\na=json.loads(sys.stdin.read())\nprint(json.dumps(upsert_incident(a["job_id"], a["error"], job_name=a.get("job_name"))))';
60
- const run = spawnSync(install.python, ['-c', code], {
61
- input: JSON.stringify({ job_id, job_name, error: String(error ?? 'failed') }),
62
- encoding: 'utf8', timeout: 30_000, cwd: install.source,
63
- env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source },
64
- });
65
- if (run.status !== 0) return { id: null, why: (run.stderr || run.error?.message || '').trim().slice(-300) };
66
- const [id] = JSON.parse(run.stdout.trim().split('\n').pop());
67
- return { id };
52
+ /** Record native cron failure grouping without requiring a separate Hermes Python installation. */
53
+ export function recordIncident(profileDir, { job_id, error }) {
54
+ try { return recordCronIncident(profileDir, { job_id, error: String(error ?? 'failed'), redacted_error: redactText(String(error ?? 'failed')) }); }
55
+ catch (error) { return { id: null, why: error.message }; }
68
56
  }
69
57
 
70
58
  // Access through Hermes's own pairing store and writers (docs/plans/hermes-compat.md row 8): a code is
package/index.d.ts CHANGED
@@ -443,7 +443,7 @@ export interface McpServerLaunch { name: string; command: string; arguments: str
443
443
  export function mountOrchestratorMcp(o: {
444
444
  root: Path; profile: Profile; harness: string; surfaceKey?: string | null;
445
445
  launch?: { program: string; arguments?: string[]; env?: Record<string, string> };
446
- socketPath?: Path; serverPath?: Path; program?: string;
446
+ socketPath?: Path; program?: string;
447
447
  /** The Teams id of the agent the session is; the rh2-agent record installed for that id (`--agent`) is mounted beside
448
448
  * the orchestrator server as `rh2-agent`. */
449
449
  agentId?: string | null;
package/loop.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
8
8
  import { basename, join, dirname } from 'node:path';
9
+ import { parseSessionWire, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
9
10
  import { step, leaseId } from './reducer.mjs';
10
11
  import * as events from './events.mjs';
11
12
  import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
@@ -278,9 +279,18 @@ export class Orchestrator {
278
279
  try {event={...event,identity:await this.identityRecord.observe({observation,location:JSON.stringify(event.key),at:Date.parse(event.at),name:event.sender_name,evidence:{adapter:event.adapter,message_id:event.message_id}})};}
279
280
  catch(error){event={...event,identity:{observation,principal:null,classification:'unresolved',label:'unresolved'}};this.log('warn',`identity observation unavailable: ${error.message}`);}
280
281
  }
282
+ const before = parseSessionWire(stringifySessionWire(this.state));
281
283
  const effects = step(this.state, event);
282
- for (const effect of effects) {
284
+ const firstSave = effects.findIndex(effect => effect.type === 'save');
285
+ if (firstSave >= 0) {
286
+ // Persist the reducer's claims before any external action. A refused save must leave no in-memory gate or
287
+ // hosted-fire marker behind: otherwise the next tick thinks an execution that never started is still alive.
288
+ try { await this.save(); }
289
+ catch (error) { this.state = before; throw error; }
290
+ }
291
+ for (const [index, effect] of effects.entries()) {
283
292
  this.effectLog.push(effect);
293
+ if (index === firstSave) continue;
284
294
  await this.perform(effect);
285
295
  }
286
296
  return effects;