@volter/supercode-orchestrator 0.4.104 → 0.4.106

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.
@@ -176,6 +176,16 @@ export function lanesOf(profiles = {}) {
176
176
 
177
177
  function supervise(board, fleet, settings, report, entry, effects) {
178
178
  const rows = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
179
+ // The catalog can lag a pane. Read the actual machines once, before any board mutation.
180
+ const observed = new Map();
181
+ for (const { id } of rows) {
182
+ const s = currentRun(board.db, id)?.metadata?.supercode;
183
+ if (!s?.pane || observed.has(s.machine)) continue;
184
+ const r = sc(['teams', 'panes', 'ls', '--machine', s.machine ?? localMachine()], { timeout: 60_000 });
185
+ const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
186
+ observed.set(s.machine, Array.isArray(list) ? new Set(list.map((p) => p.pane)) : null);
187
+ if (!Array.isArray(list)) report.failed.push([id, `pane liveness unavailable; retaining run: ${(r.stderr || r.stdout).trim().slice(-200)}`]);
188
+ }
179
189
  for (const { id } of rows) {
180
190
  tx(board, (db) => {
181
191
  const task = getTask(db, id);
@@ -197,7 +207,9 @@ function supervise(board, fleet, settings, report, entry, effects) {
197
207
  };
198
208
  const limits = limitsOf(db, board, entry, task, session);
199
209
  // Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
200
- const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
210
+ const paneReading = session?.pane ? observed.get(session.machine) : undefined;
211
+ const paneLive = paneReading instanceof Set ? paneReading.has(session.pane) : null;
212
+ const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.pane ? paneLive !== false : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
201
213
  // (whether it is reconciled is the workflow's: its `orphaned` transitions and their guard)
202
214
  if ((!task.claim_lock || !task.claim_expires) && !live) {
203
215
  observe('orphaned', { reason: 'orphaned_running', claim_lock: task.claim_lock, claim_expires: task.claim_expires, error: 'orphaned running card (broken claim bookkeeping)' }, 'reclaimed');
@@ -226,20 +238,21 @@ function supervise(board, fleet, settings, report, entry, effects) {
226
238
  return;
227
239
  }
228
240
  if (session?.session_id) {
229
- if (!fleet.ok) return; // no reading, no verdict
230
- const alive = sessionAlive(fleet, session);
231
- const machineUp = fleet.online.has(session.machine);
241
+ if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
242
+ if (!session.pane && !fleet.ok) return;
243
+ const alive = session.pane ? paneLive : sessionAlive(fleet, session);
244
+ const machineUp = paneReading instanceof Set || fleet.online.has(session.machine);
232
245
  const young = t - Number(session.launched_at ?? 0) < Number(limits.grace ?? settings.grace);
233
246
  const missing = !alive && machineUp && !young;
234
247
  const next = { ...session };
235
248
  // a machine that is off the fleet: how long, for the workflow's machine_offline (the claim is kept meanwhile)
236
- if (machineUp) delete next.offline_since; else next.offline_since ??= t;
249
+ if (machineUp) next.offline_since = null; else next.offline_since ??= t;
237
250
  if (!machineUp) {
238
251
  setRunSession(db, runRow.id, next);
239
252
  observe('machine_offline', { machine: session.machine, seconds: t - next.offline_since }, null);
240
253
  if (getTask(db, id)?.current_run_id !== runRow.id) return; // the workflow moved it
241
254
  }
242
- if (alive) { next.seen_at ??= t; delete next.missing_since; }
255
+ if (alive) { next.seen_at ??= t; next.missing_since = null; }
243
256
  if (missing) next.missing_since ??= t;
244
257
  if (missing && t - next.missing_since >= Number(limits.grace ?? settings.grace)) {
245
258
  // lost: gone from an online machine for the whole grace. A resume that never came up is not resumable.
@@ -286,7 +299,7 @@ function stopSession(board, session) {
286
299
  }
287
300
  if (session.pane) {
288
301
  const r = sc(['teams', 'panes', 'close', session.pane, ...(session.machine ? ['--machine', session.machine] : [])], { timeout: 60_000, as: boardAddress(board.label) });
289
- 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)}`);
302
+ 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)}`);
290
303
  }
291
304
  }
292
305
 
@@ -471,10 +484,10 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
471
484
  const session = json(row.metadata, {})?.supercode;
472
485
  if (!session?.pane || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
473
486
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
474
- 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());
487
+ 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());
475
488
  if (active.some((r) => {
476
489
  const s = json(r.metadata, {})?.supercode;
477
- return r.id !== row.id && s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine);
490
+ return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine);
478
491
  })) continue;
479
492
  // Persist retries even for historical ends that did not have a close_session action.
480
493
  tx(board, (db) => requestSessionClose(db, row.task_id, { runId: row.id }));
@@ -488,7 +501,12 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
488
501
  }
489
502
  const observed = panes.get(machine);
490
503
  if (observed.error) throw new Error(observed.error);
491
- if (observed.live.has(session.pane)) { stopSession(board, session); observed.live.delete(session.pane); }
504
+ if (observed.live.has(session.pane)) {
505
+ 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())
506
+ .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
507
+ if (protectedNow) continue;
508
+ stopSession(board, session); observed.live.delete(session.pane);
509
+ }
492
510
  } catch (e) { error = String(e.message).slice(-500); }
493
511
  tx(board, (db) => {
494
512
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
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,6 +523,12 @@ 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
+ }
526
532
  // Every import is event-based. Existing unconsumed acts must never be erased by an export race.
527
533
  const cursor = this.get('hermes_cursor', 0);
528
534
  const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
@@ -535,14 +541,49 @@ class ZtrackBoard {
535
541
  for (const {name,key,row:source} of sources.values()) {
536
542
  if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
537
543
  const table = this.rows.tables.get(name);
544
+ if (name === 'task_events') {
545
+ const sourceId = JSON.parse(key)[0];
546
+ const mapped = compat.prepare('SELECT event_id FROM supercode_export_events WHERE source_id = ?').get(sourceId);
547
+ if (!source) {
548
+ if (mapped) compat.prepare('DELETE FROM task_events WHERE id = ?').run(mapped.event_id);
549
+ compat.prepare('DELETE FROM supercode_export_events WHERE source_id = ?').run(sourceId);
550
+ continue;
551
+ }
552
+ const row = { ...source, payload: JSON.stringify({ ...json(source.payload, {}), __supercode_export: true }) };
553
+ delete row.id;
554
+ const keys = Object.keys(row);
555
+ if (mapped) {
556
+ const previous = compat.prepare('SELECT * FROM task_events WHERE id = ?').get(mapped.event_id);
557
+ if (previous && keys.every((k) => previous[k] === row[k])) continue;
558
+ compat.prepare(`UPDATE task_events SET ${keys.map((k) => `${quote(k)} = ?`).join(',')} WHERE id = ?`).run(...keys.map((k) => row[k]), mapped.event_id);
559
+ } else {
560
+ const inserted = compat.prepare(`INSERT INTO task_events (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => row[k]));
561
+ compat.prepare('INSERT INTO supercode_export_events (source_id, event_id) VALUES (?, ?)').run(sourceId, inserted.lastInsertRowid);
562
+ }
563
+ continue;
564
+ }
538
565
  if (!source) { compat.prepare(`DELETE FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key)); continue; }
539
566
  const row = {...source};
540
- if (name === 'task_events') row.payload = JSON.stringify({...json(row.payload,{}),__supercode_export:true});
541
567
  const keys=Object.keys(row);
542
568
  const previous=compat.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key));
543
569
  if (previous && keys.every(k=>previous[k]===row[k]))continue;
544
570
  compat.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(()=>'?')})`).run(...keys.map(k=>row[k]));
545
571
  }
572
+ // Hermes recomputes ready on read. Its sticky block is the last blocked/unblocked event,
573
+ // so lowering the status alone cannot preserve a pause (including one made by a document edit).
574
+ for (const id of projected) {
575
+ 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);
576
+ if (!state) continue;
577
+ const sticky = state.status === 'blocked' && (json(state.paused, null) || json(state.blocked_by, []).some((b) => b.kind === 'message'));
578
+ 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);
579
+ if (sticky) compat.prepare("UPDATE tasks SET block_kind = 'needs_input' WHERE id = ?").run(id);
580
+ const cleared = !sticky && json(last?.payload, {})?.__supercode_sticky;
581
+ if ((sticky && last?.kind !== 'blocked') || (cleared && last?.kind === 'blocked')) {
582
+ const payload = { __supercode_export: true, __supercode_sticky: true, block_kind: 'needs_input', source_status: state.stage,
583
+ reason: json(state.paused, null)?.reason ?? (sticky ? 'waiting on a message' : 'lowered hold cleared') };
584
+ compat.prepare('INSERT INTO task_events (task_id, kind, payload, created_at) VALUES (?, ?, ?, ?)').run(id, sticky ? 'blocked' : 'unblocked', JSON.stringify(payload), now());
585
+ }
586
+ }
546
587
  compat.exec('COMMIT');
547
588
  // Exported events carry an origin marker and never advance the Hermes input cursor.
548
589
  } 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.104",
3
+ "version": "0.4.106",
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": {