@volter/supercode-orchestrator 0.4.105 → 0.4.107

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.
@@ -63,6 +63,8 @@ function sc(args, { input = null, timeout = 120_000, as = null } = {}) {
63
63
  }
64
64
 
65
65
  const lastJson = (text) => {
66
+ const whole = json(text.trim(), null);
67
+ if (whole && typeof whole === 'object') return whole;
66
68
  const line = text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('{') || l.startsWith('[')).pop();
67
69
  return line ? json(line, null) : null;
68
70
  };
@@ -120,6 +122,16 @@ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0,
120
122
 
121
123
  const sessionAlive = (fleet, session) => Boolean(session?.session_id) && [...fleet.alive].some((a) => a.endsWith(`:${session.session_id}`));
122
124
 
125
+ /** Match a manual continuation by the same harness identity and an actually live machine pane. */
126
+ function continuationPane(fleet, session, panes) {
127
+ if (!session?.session_id || !(panes instanceof Set)) return null;
128
+ const candidates = new Set((fleet.sessions ?? []).filter((s) => s.address === addressOf(session) && s.pane && panes.has(s.pane)).map((s) => s.pane));
129
+ if (session.pane && panes.has(session.pane)) candidates.add(session.pane);
130
+ if (candidates.size > 1) throw new Error(`session ${session.session_id} has multiple live panes: ${[...candidates].join(', ')}; close the extra pane before continuing`);
131
+ return [...candidates][0] ?? null;
132
+ }
133
+
134
+
123
135
  function pidAlive(pid) {
124
136
  if (!pid) return false;
125
137
  try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
@@ -191,7 +203,17 @@ function supervise(board, fleet, settings, report, entry, effects) {
191
203
  const task = getTask(db, id);
192
204
  if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
193
205
  const runRow = currentRun(db, id);
194
- const session = runRow?.metadata?.supercode ?? null;
206
+ let session = runRow?.metadata?.supercode ?? null;
207
+ if (session?.pane) {
208
+ let found;
209
+ try { found = continuationPane(fleet, session, observed.get(session.machine)); }
210
+ catch (error) { report.failed.push([id, error.message]); return; }
211
+ if (found && found !== session.pane) {
212
+ session = { ...session, pane: found, seen_at: now(), missing_since: null, close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null };
213
+ setRunSession(db, runRow.id, session);
214
+ event(db, id, 'session_rebound', { session_id: session.session_id, pane: found }, runRow.id);
215
+ }
216
+ }
195
217
  const t = now();
196
218
  // what the dispatcher saw becomes a platform event; the card's workflow says what it does
197
219
  const observe = (name, payload, bucket) => {
@@ -299,7 +321,7 @@ function stopSession(board, session) {
299
321
  }
300
322
  if (session.pane) {
301
323
  const r = sc(['teams', 'panes', 'close', session.pane, ...(session.machine ? ['--machine', session.machine] : [])], { timeout: 60_000, as: boardAddress(board.label) });
302
- if (r.code !== 0 || lastJson(r.stdout)?.closed !== session.pane) throw new Error(`closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
324
+ if (r.code !== 0 || json(r.stdout.trim(), null)?.closed !== session.pane) throw new Error(`closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
303
325
  }
304
326
  }
305
327
 
@@ -484,11 +506,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
484
506
  const session = json(row.metadata, {})?.supercode;
485
507
  if (!session?.pane || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
486
508
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
487
- const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id`).all());
488
- if (active.some((r) => {
489
- const s = json(r.metadata, {})?.supercode;
490
- return r.id !== row.id && s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine);
491
- })) continue;
509
+ const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all());
492
510
  // Persist retries even for historical ends that did not have a close_session action.
493
511
  tx(board, (db) => requestSessionClose(db, row.task_id, { runId: row.id }));
494
512
  let error = null;
@@ -501,7 +519,12 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
501
519
  }
502
520
  const observed = panes.get(machine);
503
521
  if (observed.error) throw new Error(observed.error);
504
- if (observed.live.has(session.pane)) { stopSession(board, session); observed.live.delete(session.pane); }
522
+ if (observed.live.has(session.pane)) {
523
+ const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
524
+ .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
525
+ if (protectedNow) continue;
526
+ stopSession(board, session); observed.live.delete(session.pane);
527
+ }
505
528
  } catch (e) { error = String(e.message).slice(-500); }
506
529
  tx(board, (db) => {
507
530
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
@@ -671,6 +694,16 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
671
694
  .find((r) => r.session?.pane && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
672
695
  if (pending) throw new Error(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
673
696
  let prev = view(board, (db) => lastSession(db, task.id, slot));
697
+ if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
698
+ const fresh = observeFleet();
699
+ if (!fresh.ok) throw new Error(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
700
+ const r = sc(['teams', 'panes', 'ls', '--machine', machine], { timeout: 60_000 });
701
+ const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
702
+ if (!Array.isArray(listed)) throw new Error(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
703
+ const pane = continuationPane(fresh, prev, new Set(listed.map((p) => p.pane)));
704
+ if (pane && pane !== prev.pane) prev = { ...prev, pane, close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null };
705
+ fleet = fresh;
706
+ }
674
707
  if (prev?.close_requested && !prev.closed_at) {
675
708
  const report = { undelivered: [] };
676
709
  closeRequested(board, report, { taskId: task.id });
@@ -881,6 +914,12 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
881
914
  if (!found) throw new Error(`the fleet lists no live session ${who}`);
882
915
  const [, host, harness, sessionId] = found.address.split(':');
883
916
  if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
917
+ if (!found.pane || !panePresent(host, found.pane)) throw new Error(`${found.address} has no verified live pane`);
918
+ const beforeRun = view(board, (db) => currentRun(db, id));
919
+ const beforeSession = beforeRun?.metadata?.supercode;
920
+ if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
921
+ if (beforeSession?.pane && beforeSession.pane !== found.pane && panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
922
+
884
923
  // the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
885
924
  let dir = workspace;
886
925
  if (!dir) {
@@ -894,7 +933,8 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
894
933
  return tx(board, (db) => {
895
934
  const task = getTask(db, id);
896
935
  if (!task) throw new Error(`no card ${id}`);
897
- if (task.status === 'running') throw new Error(`${id} is running already`);
936
+ if ((task.current_run_id ?? null) !== (beforeRun?.id ?? null)) throw new Error(`${id} changed runs during adoption; retry against its current run`);
937
+ if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
898
938
  if (task.machine && normalMachine(task.machine) !== host) throw new Error(`${id} runs on ${task.machine}, and ${found.address} is on ${host}; move the card first`);
899
939
  const was = lastSession(db, id);
900
940
  if (was && was.session_id !== sessionId && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
@@ -905,11 +945,12 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
905
945
  db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
906
946
  }
907
947
  // the workflow's adopt event: the card is claimed for its implementer, whose session this now is
908
- fire(db, board, workflowFor(board.root), id, 'adopt', { roles: ['manager'], payload: { lock: claimer() } });
948
+ const entry = workflowFor(board.root);
949
+ if (!beforeRun) fire(db, board, entry, id, entry.workflow.run ? entry.workflow.dispatch.start : 'adopt', { roles: ['manager'], payload: { lock: claimer() } });
909
950
  const runId = getTask(db, id).current_run_id;
910
951
  if (!runId) throw new Error(`${id} waits on parents that are not done`);
911
952
  const session = { surface: 'pane', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
912
- setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, slot: 'worker' });
953
+ setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, slot: beforeSession?.slot ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
913
954
  event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt' }, runId);
914
955
  subscribeIn(db, id, found.address, 'worker');
915
956
  for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
package/board/engine.mjs CHANGED
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { DatabaseSync } from 'node:sqlite';
14
14
  import { view, claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
- import { refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
15
+ import { addDependency, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
16
16
 
17
17
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
18
18
  export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
@@ -191,9 +191,9 @@ export function createCard(board, spec) {
191
191
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
192
192
  const machine = spec.machine ?? arc?.machine ?? null;
193
193
  if (machine || arc) run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)', id, machine, arc?.id ?? null);
194
- for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
195
- // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
196
194
  const entry = workflowFor(board.root);
195
+ for (const p of parents) addDependency(db, entry, id, p);
196
+ // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
197
197
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
198
198
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
199
199
  every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
@@ -521,6 +521,9 @@ const ACTIONS = {
521
521
  blocks = blocks.filter((b) => remove === 'messages' ? b.kind !== 'message' : remove !== '*' && b.id !== remove);
522
522
  }
523
523
  cardField(ctx, 'blocked_by', JSON.stringify(blocks));
524
+ // task_links is the Hermes projection of the canonical card relations, including within this transaction.
525
+ run(ctx.db, 'DELETE FROM task_links WHERE child_id = ?', ctx.id);
526
+ for (const block of blocks.filter((b) => b.kind === 'card')) run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', block.id, ctx.id);
524
527
  },
525
528
  flag(ctx, { name, value, reason = null }) {
526
529
  if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
@@ -712,6 +715,12 @@ const ACTIONS = {
712
715
  send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
713
716
  };
714
717
 
718
+ /** The create door uses the instance's canonical dependency representation. */
719
+ export function addDependency(db, entry, id, parent) {
720
+ if (entry.workflow.relations?.blocked_by) ACTIONS.relation({ db, id }, { add: parent, reason: 'declared dependency' });
721
+ else run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, id);
722
+ }
723
+
715
724
  /** Persist close intent in the same transaction as the transition; I/O can fail or never run. */
716
725
  export function requestSessionClose(db, id, { runId = null, slot = null, through = null } = {}) {
717
726
  for (const row of all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ?', id)) {
package/board/ztrack.mjs CHANGED
@@ -523,11 +523,23 @@ class ZtrackBoard {
523
523
  const compat = new DatabaseSync(this.compatPath);
524
524
  try {
525
525
  compat.exec('BEGIN'); this.init(compat, { fresh: true });
526
+ // Native events and Hermes events share an export database, not an id sequence.
527
+ // Bootstrap the old identity mapping once; new exports allocate independent Hermes ids.
528
+ if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_export_events'").get()) {
529
+ compat.exec(`CREATE TABLE supercode_export_events (source_id INTEGER PRIMARY KEY, event_id INTEGER NOT NULL UNIQUE)`);
530
+ compat.exec(`INSERT INTO supercode_export_events SELECT id, id FROM task_events WHERE json_extract(payload, '$.__supercode_export') = 1`);
531
+ // Repair existing lowered holds on upgrade, once; subsequent exports stay proportional to changes.
532
+ for (const state of this.mem.prepare('SELECT task_id, paused, blocked_by FROM supercode_cards').all()) {
533
+ if (!json(state.paused, null) && !json(state.blocked_by, []).some((b) => b.kind === 'message')) continue;
534
+ projected.add(state.task_id); projectStatus(this.mem, this.board(), entry, state.task_id);
535
+ }
536
+ }
526
537
  // Every import is event-based. Existing unconsumed acts must never be erased by an export race.
527
538
  const cursor = this.get('hermes_cursor', 0);
528
539
  const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
529
540
  if (unseen) throw new Error('Hermes wrote during board export; retained its events for the next read');
530
541
  const sources = new Map(changes.map(c => [`${c.source.name}:${c.source.key}`, c.source]));
542
+ for (const id of projected) sources.set(`tasks:${JSON.stringify([id])}`, { name: 'tasks', key: JSON.stringify([id]), row: this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(id) });
531
543
  for (const source of [...sources.values()]) {
532
544
  const id = source.name === 'tasks' ? source.row?.id : source.name === 'supercode_cards' ? source.row?.task_id : null;
533
545
  if (id) sources.set(`tasks:${JSON.stringify([id])}`, {name:'tasks',key:JSON.stringify([id]),row:this.mem.prepare('SELECT * FROM tasks WHERE id=?').get(id)});
@@ -535,14 +547,49 @@ class ZtrackBoard {
535
547
  for (const {name,key,row:source} of sources.values()) {
536
548
  if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
537
549
  const table = this.rows.tables.get(name);
550
+ if (name === 'task_events') {
551
+ const sourceId = JSON.parse(key)[0];
552
+ const mapped = compat.prepare('SELECT event_id FROM supercode_export_events WHERE source_id = ?').get(sourceId);
553
+ if (!source) {
554
+ if (mapped) compat.prepare('DELETE FROM task_events WHERE id = ?').run(mapped.event_id);
555
+ compat.prepare('DELETE FROM supercode_export_events WHERE source_id = ?').run(sourceId);
556
+ continue;
557
+ }
558
+ const row = { ...source, payload: JSON.stringify({ ...json(source.payload, {}), __supercode_export: true }) };
559
+ delete row.id;
560
+ const keys = Object.keys(row);
561
+ if (mapped) {
562
+ const previous = compat.prepare('SELECT * FROM task_events WHERE id = ?').get(mapped.event_id);
563
+ if (previous && keys.every((k) => previous[k] === row[k])) continue;
564
+ compat.prepare(`UPDATE task_events SET ${keys.map((k) => `${quote(k)} = ?`).join(',')} WHERE id = ?`).run(...keys.map((k) => row[k]), mapped.event_id);
565
+ } else {
566
+ const inserted = compat.prepare(`INSERT INTO task_events (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => row[k]));
567
+ compat.prepare('INSERT INTO supercode_export_events (source_id, event_id) VALUES (?, ?)').run(sourceId, inserted.lastInsertRowid);
568
+ }
569
+ continue;
570
+ }
538
571
  if (!source) { compat.prepare(`DELETE FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key)); continue; }
539
572
  const row = {...source};
540
- if (name === 'task_events') row.payload = JSON.stringify({...json(row.payload,{}),__supercode_export:true});
541
573
  const keys=Object.keys(row);
542
574
  const previous=compat.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key));
543
575
  if (previous && keys.every(k=>previous[k]===row[k]))continue;
544
576
  compat.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(()=>'?')})`).run(...keys.map(k=>row[k]));
545
577
  }
578
+ // Hermes recomputes ready on read. Its sticky block is the last blocked/unblocked event,
579
+ // so lowering the status alone cannot preserve a pause (including one made by a document edit).
580
+ for (const id of projected) {
581
+ const state = compat.prepare('SELECT t.status, c.stage, c.paused, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE t.id = ?').get(id);
582
+ if (!state) continue;
583
+ const sticky = state.status === 'blocked' && (json(state.paused, null) || json(state.blocked_by, []).some((b) => b.kind === 'message'));
584
+ const last = compat.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked','unblocked') ORDER BY id DESC LIMIT 1").get(id);
585
+ if (sticky) compat.prepare("UPDATE tasks SET block_kind = 'needs_input' WHERE id = ?").run(id);
586
+ const cleared = !sticky && json(last?.payload, {})?.__supercode_sticky;
587
+ if ((sticky && last?.kind !== 'blocked') || (cleared && last?.kind === 'blocked')) {
588
+ const payload = { __supercode_export: true, __supercode_sticky: true, block_kind: 'needs_input', source_status: state.stage,
589
+ reason: json(state.paused, null)?.reason ?? (sticky ? 'waiting on a message' : 'lowered hold cleared') };
590
+ compat.prepare('INSERT INTO task_events (task_id, kind, payload, created_at) VALUES (?, ?, ?, ?)').run(id, sticky ? 'blocked' : 'unblocked', JSON.stringify(payload), now());
591
+ }
592
+ }
546
593
  compat.exec('COMMIT');
547
594
  // Exported events carry an origin marker and never advance the Hermes input cursor.
548
595
  } catch (error) { try { compat.exec('ROLLBACK'); } catch {} throw error; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.105",
3
+ "version": "0.4.107",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {