@volter/supercode-orchestrator 0.5.211 → 0.5.213

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.
@@ -26,10 +26,77 @@ function connection() {
26
26
  resource TEXT, used INTEGER, remaining INTEGER, reset_at INTEGER, retry_after INTEGER);
27
27
  CREATE INDEX IF NOT EXISTS github_attempts_window ON github_attempts(credential,started_at,purpose);
28
28
  CREATE INDEX IF NOT EXISTS github_attempts_home_window ON github_attempts(started_at,purpose);
29
- CREATE TABLE IF NOT EXISTS github_cooldown (credential TEXT PRIMARY KEY, until_at INTEGER NOT NULL, reason TEXT NOT NULL);`);
29
+ CREATE TABLE IF NOT EXISTS github_cooldown (credential TEXT PRIMARY KEY, until_at INTEGER NOT NULL, reason TEXT NOT NULL);
30
+ CREATE TABLE IF NOT EXISTS github_discovery (
31
+ key TEXT PRIMARY KEY, observed_at INTEGER NOT NULL, until_at INTEGER NOT NULL,
32
+ value TEXT, error TEXT, claim TEXT, claim_until INTEGER NOT NULL DEFAULT 0);
33
+ CREATE TABLE IF NOT EXISTS github_refusals (
34
+ id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, credential TEXT NOT NULL, machine TEXT NOT NULL,
35
+ pid INTEGER NOT NULL, caller TEXT NOT NULL, purpose TEXT NOT NULL, method TEXT NOT NULL,
36
+ endpoint TEXT NOT NULL, code TEXT NOT NULL, reason TEXT NOT NULL);
37
+ CREATE INDEX IF NOT EXISTS github_refusals_window ON github_refusals(started_at,purpose);
38
+ CREATE TABLE IF NOT EXISTS github_observation_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);`);
39
+ db.prepare("INSERT OR IGNORE INTO github_observation_meta(key,value) VALUES('refusals_since',?)").run(Date.now());
30
40
  connections.set(path, db);
31
41
  return db;
32
42
  }
43
+
44
+ const discoveryReads = new Map();
45
+ const discoveryPending = () => Object.assign(new Error('GitHub discovery is already being read by another process; no duplicate read was started'), { code: 'github_discovery_pending', deferred: true });
46
+ /** Advisory observations survive command/owner restarts. Fresh transition reads never consume a cached value,
47
+ * but publish their successful observation for discovery. A short transaction claims each advisory read; no
48
+ * transaction spans I/O and a different process defers rather than waiting or duplicating its wire attempts. */
49
+ export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlOf = () => ttl, cacheErrors = true } = {}) {
50
+ const key = createHash('sha256').update(identity).digest('hex');
51
+ const localKey = `${pathOf()}\0${key}`;
52
+ if (!fresh && discoveryReads.has(localKey)) return discoveryReads.get(localKey);
53
+ const work = async () => {
54
+ const db = connection(), began = Date.now(), claim = randomUUID();
55
+ if (!fresh) {
56
+ let cached = null;
57
+ db.exec('BEGIN IMMEDIATE');
58
+ try {
59
+ const held = db.prepare('SELECT * FROM github_discovery WHERE key=?').get(key);
60
+ if (held && held.until_at > began && (held.value != null || held.error != null)) {
61
+ cached = held;
62
+ } else {
63
+ if (held?.claim_until > began) throw discoveryPending();
64
+ db.prepare(`INSERT INTO github_discovery(key,observed_at,until_at,claim,claim_until) VALUES(?,0,0,?,?)
65
+ ON CONFLICT(key) DO UPDATE SET claim=excluded.claim,claim_until=excluded.claim_until`).run(key, claim, began + 360_000);
66
+ }
67
+ db.exec('COMMIT');
68
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
69
+ if (cached) {
70
+ if (cached.error) { const failure = JSON.parse(cached.error); throw Object.assign(new Error(failure.message), failure); }
71
+ return JSON.parse(cached.value);
72
+ }
73
+ }
74
+ const retain = (value, error, duration = ttl) => {
75
+ db.prepare(`INSERT INTO github_discovery(key,observed_at,until_at,value,error) VALUES(?,?,?,?,?)
76
+ ON CONFLICT(key) DO UPDATE SET observed_at=excluded.observed_at,until_at=excluded.until_at,
77
+ value=excluded.value,error=excluded.error,claim=NULL,claim_until=0
78
+ WHERE github_discovery.observed_at<=excluded.observed_at`).run(key, began, Date.now() + duration, value, error);
79
+ };
80
+ try {
81
+ const value = await read();
82
+ retain(JSON.stringify(value), null, ttlOf(value));
83
+ return value;
84
+ } catch (error) {
85
+ if (!fresh && cacheErrors) retain(null, JSON.stringify({ message: String(error.message).slice(0, 1000), code: error.code, deferred: error.deferred, retry_at: error.retry_at }));
86
+ throw error;
87
+ } finally {
88
+ db.prepare('UPDATE github_discovery SET claim=NULL,claim_until=0 WHERE key=? AND claim=?').run(key, claim);
89
+ // Retain only live observations/claims. Expired advisory data has no evidentiary role.
90
+ db.prepare('DELETE FROM github_discovery WHERE until_at<=? AND claim_until<=?').run(Date.now(), Date.now());
91
+ }
92
+ };
93
+ const pending = work();
94
+ if (!fresh) {
95
+ discoveryReads.set(localKey, pending);
96
+ try { return await pending; } finally { if (discoveryReads.get(localKey) === pending) discoveryReads.delete(localKey); }
97
+ }
98
+ return pending;
99
+ }
33
100
  const refusal = (message) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET' });
34
101
  const integer = (value) => value != null && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
35
102
  function endpointOf(url) {
@@ -63,7 +130,15 @@ async function issue(url, options, { purpose }) {
63
130
  db.prepare('INSERT INTO github_attempts(id,credential,started_at,machine,pid,caller,purpose,method,endpoint) VALUES(?,?,?,?,?,?,?,?,?)')
64
131
  .run(id, credential, at, hostname(), process.pid, caller, purpose, method, endpoint);
65
132
  db.exec('COMMIT');
66
- } catch (error) { db.exec('ROLLBACK'); throw error; }
133
+ } catch (error) {
134
+ db.exec('ROLLBACK');
135
+ // A refused action made no wire attempt. Keep its receipt separately so a full-hour qualification can read
136
+ // actual budget refusals as well as usage, without inflating the allowance or inferring success from silence.
137
+ if (error.code === 'GITHUB_BUDGET') db.prepare(`INSERT INTO github_refusals
138
+ (id,started_at,credential,machine,pid,caller,purpose,method,endpoint,code,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)`)
139
+ .run(id, at, credential, hostname(), process.pid, caller, purpose, method, endpoint, error.code, error.message);
140
+ throw error;
141
+ }
67
142
  let response, outcome = 'unknown';
68
143
  try { response = await fetch(url, { ...options, redirect: 'manual' }); outcome = response.ok ? 'ok' : `HTTP_${response.status}`; return response; }
69
144
  catch (error) { outcome = error.code ?? error.name ?? 'transport_error'; throw error; }
@@ -109,7 +184,14 @@ export function githubUsage({ since = Date.now() - HOUR } = {}) {
109
184
  if (!existsSync(path)) return { path, recorded: false, callers: [], endpoints: [] };
110
185
  const db = new DatabaseSync(path, { readOnly: true });
111
186
  try {
187
+ const hasRefusals = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='github_refusals'").get());
188
+ const hasMeta = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='github_observation_meta'").get());
189
+ const refusalsSince = hasMeta ? db.prepare("SELECT value FROM github_observation_meta WHERE key='refusals_since'").get()?.value ?? null : null;
112
190
  return { path, recorded: true, since, limits: { hourly: GITHUB_HOURLY_ATTEMPTS, discovery: GITHUB_DISCOVERY_ATTEMPTS },
191
+ refusals_recorded: hasRefusals, refusals_since: refusalsSince, refusals_complete: refusalsSince != null && refusalsSince <= since,
192
+ refusals: hasRefusals ? db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,code,COUNT(*) refusals,
193
+ MIN(started_at) first_at,MAX(started_at) last_at FROM github_refusals WHERE started_at>=?
194
+ GROUP BY credential,machine,pid,caller,purpose,method,endpoint,code`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })) : [],
113
195
  callers: db.prepare(`SELECT credential,machine,pid,caller,purpose,COUNT(*) attempts,SUM(status IS NULL) unanswered
114
196
  FROM github_attempts WHERE started_at>=? GROUP BY credential,machine,pid,caller,purpose`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })),
115
197
  endpoints: db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,status,COUNT(*) attempts
@@ -126,6 +126,7 @@ export function auditEndedRuns(db, board, entry) {
126
126
  if (decided) continue; // its manager or creator ended it by an explicit act
127
127
  const audited = db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='audited'").get(task.id,task.run_id);
128
128
  if (audited) continue; // Every explicitly schedules the next successful audit
129
+ if (session?.retain_after_run && task.outcome === 'completed') continue; // an explicit finish hands work to review
129
130
  if (['blocked','paused','reconfigured','review_passed','review','review_requested','audited'].includes(task.outcome)) continue;
130
131
  surfaceIncident(db, board, entry, task.id, 'run_ended', { error: task.error, outcome: task.outcome }, task.run_id);
131
132
  }
@@ -112,7 +112,9 @@ export async function readMachineFleet(machines, local) {
112
112
  // what a session that needs input says it waits for, what started its open turn (Codex: `goal` for a thread
113
113
  // goal's continuation) and since when, where its harness publishes them
114
114
  waiting_for: activity.evidence?.waiting_for ?? null, turn_source: activity.evidence?.source ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null,
115
- turn_trigger: activity.evidence?.turn_trigger ?? null });
115
+ turn_trigger: activity.evidence?.turn_trigger ?? null,
116
+ // the question its pane shows and its options, where its machine read one off the screen (VO-3395)
117
+ question: activity.evidence?.question ?? null });
116
118
  }
117
119
  }
118
120
  const ok = online.has(local);
@@ -50,3 +50,10 @@ export function idleInterval(session, live, observedAt) {
50
50
  const since = Math.min(observedAt, Math.max(read, Number(session.watch_since ?? 0)));
51
51
  return { since, quiet: Math.max(0, observedAt - since) };
52
52
  }
53
+
54
+ /** A question a run's session shows on its pane (VO-3395), as one line: its words and each option, numbered. */
55
+ export function questionText(question) {
56
+ if (!question) return '';
57
+ const options = (question.options ?? []).map((option) => `[${option.number}] ${option.label}`).join(' ');
58
+ return `${question.question ? `${question.question} ` : ''}${options}`.trim();
59
+ }
package/board/store.mjs CHANGED
@@ -516,7 +516,7 @@ export function heldConfigWatched(slug, source, watched) {
516
516
 
517
517
  export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
518
518
  // a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
519
- if (current().readOnly) return openReadOnly(root, slug);
519
+ if (current().readOnly) return openBoardSnapshot(root, slug);
520
520
  if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
521
521
  let entry = heldBoards.get(slug);
522
522
  if (!entry) {
@@ -553,7 +553,7 @@ function heldHandle(entry, slug) {
553
553
  * reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
554
554
  * (listing.mjs byOwner).
555
555
  */
556
- function openReadOnly(root, slug) {
556
+ export function openBoardSnapshot(root, slug) {
557
557
  const use = backingOf(root, slug);
558
558
  if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
559
559
  const path = backingPaths(root, slug)[use];
@@ -382,6 +382,14 @@ function stateRequestOf(db, id) {
382
382
 
383
383
  const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
384
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
+ }
385
393
  /** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
386
394
  * and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
387
395
  * needs, added after it); no other line added, removed or changed. */
@@ -393,8 +401,8 @@ function onlyTicks(was, body) {
393
401
  // an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
394
402
  let section = null;
395
403
  return before.every((line, i) => {
396
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
397
- if (heading) section = heading[1].toLowerCase();
404
+ const heading = cardSectionHeading(line);
405
+ if (heading !== null) section = heading;
398
406
  if (line === after[i]) return true;
399
407
  if (section !== 'acceptance') return false;
400
408
  const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
@@ -487,8 +495,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
487
495
  const lines = String(task.body ?? '').split('\n');
488
496
  let acceptance = false;
489
497
  return lines.every((line) => {
490
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
491
- if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
498
+ const heading = cardSectionHeading(line);
499
+ if (heading !== null) acceptance = heading === 'acceptance';
492
500
  if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
493
501
  return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
494
502
  });
@@ -1055,7 +1063,7 @@ const ACTIONS = {
1055
1063
  else runSession(ctx, (s) => ({ ...s, [counter]: 0 }));
1056
1064
  }
1057
1065
  },
1058
- end_run(ctx, { outcome, summary = null, error = null } = {}) {
1066
+ end_run(ctx, { outcome, summary = null, error = null, close_session = true } = {}) {
1059
1067
  const row = one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id);
1060
1068
  const t = now();
1061
1069
  if (row?.current_run_id) {
@@ -1063,10 +1071,15 @@ const ACTIONS = {
1063
1071
  retireSupersededRuns(ctx.db, ctx.id, runId, json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode);
1064
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
1065
1073
  WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
1066
- requestSessionClose(ctx.db, ctx.id, { runId });
1067
1074
  ctx.runId = runId;
1068
1075
  ctx.lastOutcome = outcome;
1069
- 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
+ }
1070
1083
  } else if (summary || ctx.scope.event?.metadata) {
1071
1084
  // an outcome reached with no run open (a person completing a card nobody claimed)
1072
1085
  const task = one(ctx.db, 'SELECT assignee FROM tasks WHERE id = ?', ctx.id);
@@ -1153,8 +1166,12 @@ const ACTIONS = {
1153
1166
  if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
1154
1167
  },
1155
1168
  decompose(ctx, said) {
1156
- // the children are filed by the caller before the event (they are cards of their own); the card waits on them
1157
- 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
+ }
1158
1175
  },
1159
1176
  // card ids a handoff names that no card has (Hermes flags them the same way)
1160
1177
  check_references(ctx, said) {
@@ -1239,16 +1256,25 @@ const ACTIONS = {
1239
1256
  /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
1240
1257
  export function retireSupersededRuns(db, id, runId, session) {
1241
1258
  if (!session?.session_id && !session?.pane) return;
1242
- for (const old of all(db, `SELECT id,task_id,metadata FROM task_runs WHERE id IN (
1243
- SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1259
+ for (const old of all(db, `SELECT id,task_id,ended_at,metadata FROM task_runs WHERE id IN (
1260
+ SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
1244
1261
  AND json_extract(metadata, '$.supercode.session_id')=?
1245
- UNION SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1262
+ UNION SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
1246
1263
  AND json_extract(metadata, '$.supercode.pane')=?)`,
1247
1264
  runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
1248
1265
  const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
1249
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;
1250
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}`);
1251
- if (s.pane && s.pane === session.pane) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1268
+ const transferred = (s.pane && s.pane === session.pane) || (!s.pane && !session.pane && s.session_id === session.session_id);
1269
+ if (old.ended_at) {
1270
+ if (transferred && s.close_transferred_to !== runId) {
1271
+ whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1272
+ run(db, 'UPDATE task_runs SET metadata=? WHERE id=?', JSON.stringify(whole), old.id);
1273
+ record(db, old.task_id, 'session_transferred', { successor: runId, session_id: s.session_id, pane: s.pane ?? null }, old.id);
1274
+ }
1275
+ continue;
1276
+ }
1277
+ if (transferred) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1252
1278
  run(db, "UPDATE task_runs SET status='superseded', outcome='superseded', ended_at=?, claim_lock=NULL, claim_expires=NULL, worker_pid=NULL, metadata=? WHERE id=?", now(), JSON.stringify(whole), old.id);
1253
1279
  record(db, old.task_id, 'run_superseded', { successor: runId, session_id: s.session_id, pane: s.pane }, old.id);
1254
1280
  }
@@ -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/connection.mjs CHANGED
@@ -38,6 +38,10 @@ export async function connectionCall(record, side, method, path, body, token) {
38
38
  if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
39
39
  return payload;
40
40
  }
41
+ /** A registration that trusts a Workplace as its issuer trusts this connection's Workplace and organization, never the
42
+ * one its committed manifest names (agents.teams-app.json names production's): otherwise every connection's agents app
43
+ * trusted production's tokens, and its own Workplace's notices and views were decided as the issuer app instead. */
44
+ export function trustedIssuer(manifest,record){return manifest.issuer===undefined?{}:{issuer:record.issuer,issuer_organization:record.organization};}
41
45
  export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
42
46
  export function generateModuleConfig(root, record, grant) {
43
47
  if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
@@ -172,7 +176,7 @@ export async function connectWorkplace(flags) {
172
176
  grant.rh2=exchanged.data;privateJson(path,record);
173
177
  }
174
178
  if(selected.teamsManifest&&!grant.teams){
175
- if(!grant.registration){grant.stagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.registration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{...selected.teamsManifest,name:`${selected.teamsManifest.name} (${id.slice(0,8)})`,secret:grant.stagedSecret},owner)).data;privateJson(path,record);}
179
+ if(!grant.registration){grant.stagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.registration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{...selected.teamsManifest,...trustedIssuer(selected.teamsManifest,record),name:`${selected.teamsManifest.name} (${id.slice(0,8)})`,secret:grant.stagedSecret},owner)).data;privateJson(path,record);}
176
180
  grant.registration.secret=grant.stagedSecret;
177
181
  if(!grant.consent){grant.stagedCode??=`sci_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.consent=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps/${grant.registration.id}/installations`,{redirect_url:selected.teamsManifest.redirect_urls[0],state:id,actions:selected.teamsManifest.actions,code:grant.stagedCode},owner)).data;privateJson(path,record);}
178
182
  grant.consent.code=grant.stagedCode;
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;
package/mcp/mount.mjs CHANGED
@@ -24,13 +24,19 @@ import { SOCKET_ENV, socketPathFor } from './server.mjs';
24
24
  import { mcpServers } from '../hermes-home.mjs';
25
25
  import { KANBAN_ENV } from '../hermes-cli.mjs';
26
26
  import { installedAgentRecord } from '../installed-apps.mjs';
27
+ import { supercodeHome } from '@volter/teams/home';
27
28
 
28
29
  export { MCP_DOORS, doorFor } from '@volter/supercode-harness-sdk/mcp-doors';
29
30
 
30
- /** The stdio server this module mounts. */
31
+ /** The stdio server `supercode orchestrator mcp-server` runs (the mount names that door, never this path). */
31
32
  export const SERVER_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'server.mjs');
32
- /** The orchestrator's CLI, whose `apps mcp` runs `rh2 mcp` as an installed agent. */
33
- const ORCHESTRATOR_BIN = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/orchestrator.mjs');
33
+ /** The machine's stable `supercode` (the source pin's shim in the supercode home, else the one on PATH), which runs
34
+ * every program a session mounts (`orchestrator mcp-server`, `orchestrator apps mcp`): a config that names it names no
35
+ * release, so the pin that retires a tree leaves it working. */
36
+ function stableSupercode() {
37
+ const shim = join(supercodeHome(), 'bin', process.platform === 'win32' ? 'supercode.cmd' : 'supercode');
38
+ return existsSync(shim) ? shim : 'supercode';
39
+ }
34
40
  /** The MCP server an agent with an installed rh2-agent gets: `rh2 mcp` as that agent (its own name, so the user's own
35
41
  * `rh2` server, whatever it holds, is neither replaced nor relied on). */
36
42
  export const RH2_AGENT_SERVER = 'rh2-agent';
@@ -40,7 +46,7 @@ export const RH2_AGENT_SERVER = 'rh2-agent';
40
46
  * Teams id): a record is found by that id alone, so an agent sharing another's name is never mounted with its record. */
41
47
  function rh2AgentServer(root, agentId, program) {
42
48
  if (!installedAgentRecord(root, agentId)) return null;
43
- return { command: program, args: [ORCHESTRATOR_BIN, 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId] };
49
+ return { command: stableSupercode(), args: ['orchestrator', 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId, '--node', program] };
44
50
  }
45
51
 
46
52
  const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'session' : 'session');
@@ -55,13 +61,12 @@ const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0
55
61
  * @param {string|null} o.surfaceKey the surface this session is bound to
56
62
  * @param {object|undefined} o.launch the launch the loop resolved, if any
57
63
  * @param {string} [o.socketPath] defaults to `<root>/orchestrator.sock`
58
- * @param {string} [o.serverPath] defaults to this package's `mcp/server.mjs`
59
- * @param {string} [o.program] the interpreter, defaults to this node
64
+ * @param {string} [o.program] the node the server runs with, defaults to this node
60
65
  * @param {string|null} [o.agentId] the Teams id of the agent whose own session this is, whose installed rh2-agent is
61
66
  * mounted beside; null for a worker acting for an agent
62
67
  * @returns {{door: string, ran: string, launch: object|undefined, mcp_servers: object[]|null, path: string|null}}
63
68
  */
64
- export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, serverPath = SERVER_PATH, program = process.execPath, vault = new Map(), task = null, agentId = null }) {
69
+ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, program = process.execPath, vault = new Map(), task = null, agentId = null }) {
65
70
  const door = doorFor(harness);
66
71
  // the profile's own servers (`.mcp.json`, the workers' form; docs/plans/hermes-compat.md row 4), mounted
67
72
  // beside this one through the same door, `${VAR}` resolved from the profile's vault
@@ -81,6 +86,9 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
81
86
  // board's tools for it (docs/plans/hermes-compat.md row 14)
82
87
  const sessionEnv = { ...env, ...(surfaceKey ? { [ORCH_ENV.surface]: surfaceKey } : {}), ...(task ?? {}) };
83
88
 
89
+ // the server through the stable supercode, never this release's server.mjs: a pin that retires this tree leaves the
90
+ // session's next server start (a resume, a reconnect) working
91
+ const server = { command: stableSupercode(), args: ['orchestrator', 'mcp-server', '--node', program] };
84
92
  switch (door) {
85
93
  case 'flag': {
86
94
  if (!launch) {
@@ -88,14 +96,14 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
88
96
  }
89
97
  const file = join(home, `mcp-orchestrator-${slug(surfaceKey)}.json`);
90
98
  mkdirSync(home, { recursive: true });
91
- writeMcpConfigFile(file, { ...own, [SERVER_NAME]: { command: program, args: [serverPath], env: sessionEnv } });
99
+ writeMcpConfigFile(file, { ...own, [SERVER_NAME]: { ...server, env: sessionEnv } });
92
100
  return { door, ran: `wrote ${file} and passed --mcp-config`, launch: withMcpConfigFlag(launch, file), mcp_servers: null, path: file };
93
101
  }
94
102
 
95
103
  case 'toml': {
96
104
  const file = join(home, 'config.toml');
97
105
  mkdirSync(home, { recursive: true });
98
- const block = tomlBlock(program, serverPath, env);
106
+ const block = tomlBlock(server, env);
99
107
  let text = upsertTomlBlock(existsSync(file) ? readFileSync(file, 'utf8') : '', block, SERVER_NAME);
100
108
  const written = ownNames(home);
101
109
  for (const name of written) if (!(name in own)) text = removeTomlBlock(text, name);
@@ -115,7 +123,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
115
123
  for (const name of ownNames(home)) if (!(name in own)) delete config.mcp[name];
116
124
  for (const [name, server] of Object.entries(own)) config.mcp[name] = opencodeEntry(server);
117
125
  recordOwnNames(home, Object.keys(own));
118
- config.mcp[SERVER_NAME] = opencodeEntry({ command: program, args: [serverPath], env });
126
+ config.mcp[SERVER_NAME] = opencodeEntry({ ...server, env });
119
127
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
120
128
  return { door, ran: `wrote mcp.${SERVER_NAME} into ${file}`, launch, mcp_servers: null, path: file };
121
129
  }
@@ -131,7 +139,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
131
139
  launch,
132
140
  mcp_servers: [
133
141
  ...Object.entries(own).filter(([, server]) => server.command).map(([name, server]) => acpEntry(name, server)),
134
- acpEntry(SERVER_NAME, { command: program, args: [serverPath], env: sessionEnv }),
142
+ acpEntry(SERVER_NAME, { ...server, env: sessionEnv }),
135
143
  ],
136
144
  path: null,
137
145
  };
@@ -139,11 +147,11 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
139
147
  }
140
148
 
141
149
  /** The orchestrator's own `[mcp_servers.orchestrator]` block. */
142
- function tomlBlock(program, serverPath, env) {
150
+ function tomlBlock({ command, args }, env) {
143
151
  const lines = [
144
152
  `[mcp_servers.${SERVER_NAME}]`,
145
- `command = ${tomlString(program)}`,
146
- `args = [${tomlString(serverPath)}]`,
153
+ `command = ${tomlString(command)}`,
154
+ `args = [${args.map(tomlString).join(', ')}]`,
147
155
  ];
148
156
  for (const [k, v] of Object.entries(env)) lines.push(`env.${k} = ${tomlString(v)}`);
149
157
  // The per-session half rides codex's own env allowlist: the server is a child