@volter/supercode-orchestrator 0.4.107 → 0.4.109

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.
package/board/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { read } from '../orchestration.mjs';
12
12
  import { view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
13
13
  import { openZtrackBoard } from './ztrack.mjs';
14
14
  import * as engine from './engine.mjs';
15
- import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
15
+ import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
16
16
  import { loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
17
17
  import { evaluate } from './expr.mjs';
18
18
 
@@ -241,7 +241,7 @@ export async function runWorkflow(argv) {
241
241
  // what this command records is the calling session's act (its address, as its subscriptions name it)
242
242
  if (sid) setActor(`sc:${localMachine()}:${sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${sid}`);
243
243
  if (sid && !READS.has(verb) && boardExists(root, slug)) {
244
- withBoard((b) => { const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
244
+ withBoard((b) => { reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
245
245
  }
246
246
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
247
247
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
@@ -22,7 +22,7 @@ import { basename, dirname, join } from 'node:path';
22
22
  import { randomUUID } from 'node:crypto';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
25
- import { doors, fire, loadWorkflow, resumeOf, runSpec, requestSessionClose, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
25
+ import { doors, fire, loadWorkflow, resumeOf, runSpec, requestSessionClose, retireSupersededRuns, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
26
26
  import { evaluate, holds, render } from './expr.mjs';
27
27
  import {
28
28
  cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
@@ -204,6 +204,10 @@ function supervise(board, fleet, settings, report, entry, effects) {
204
204
  if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
205
205
  const runRow = currentRun(db, id);
206
206
  let session = runRow?.metadata?.supercode ?? null;
207
+ if (session?.launch_pending) {
208
+ extendClaim(db, id, { ttl: settings.ttl, reason: 'launch_receipt_pending' });
209
+ return; // An uncertain launch owns this run until a machine receipt settles it.
210
+ }
207
211
  if (session?.pane) {
208
212
  let found;
209
213
  try { found = continuationPane(fleet, session, observed.get(session.machine)); }
@@ -445,6 +449,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
445
449
  report.started.push([task.id, session.address ?? `pid ${session.pid}`, session.mode]);
446
450
  }
447
451
  } catch (error) {
452
+ if (error.launchPending) { report.queued.push([task.id, error.message]); continue; }
448
453
  const payload = { error: String(error.message).slice(-500), ...(error.resumeFailed ? { resume_failed: true, ...error.resumeFailed } : {}) };
449
454
  try { const r = tx(board, (db) => fire(db, board, entry, task.id, 'spawn_failed', { roles: ['platform'], payload })); effects.push(...r.effects); } catch { /* no transition for it */ }
450
455
  report.failed.push([task.id, String(error.message).slice(-300)]);
@@ -501,10 +506,37 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
501
506
  export function closeRequested(board, report, { taskId = null, caller = null } = {}) {
502
507
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
503
508
  WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
509
+ // Repair pre-transfer history once: only identities with an older unended reference need a write.
510
+ const latest = new Map(), transfers = new Map();
511
+ for (const row of [...rows].sort((a,b) => b.id-a.id)) {
512
+ const s = json(row.metadata, {})?.supercode;
513
+ if (!s) continue;
514
+ for (const identity of [s.session_id && `sid:${s.session_id}`, s.pane && `pane:${s.pane}`].filter(Boolean)) {
515
+ const key = `${row.task_id}:${s.machine}:${identity}`;
516
+ const successor = latest.get(key);
517
+ if (successor && !row.ended_at) transfers.set(successor.id, successor);
518
+ else if (!successor) latest.set(key, row);
519
+ }
520
+ }
521
+ if (transfers.size) tx(board, (db) => {
522
+ for (const row of transfers.values()) retireSupersededRuns(db, row.task_id, row.id, json(row.metadata, {})?.supercode);
523
+ });
504
524
  const panes = new Map();
505
525
  for (const row of rows) {
506
- const session = json(row.metadata, {})?.supercode;
507
- if (!session?.pane || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
526
+ let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
527
+ if (session?.launch_key && !session.pane && (row.ended_at || session.close_requested)) {
528
+ const receipt = launchReceipt(board, session);
529
+ if (receipt?.pane) { bindLaunch(board, row, session, receipt.pane, session.session_id); session = { ...session, pane: receipt.pane }; }
530
+ else {
531
+ const error = `close deferred: awaiting launch receipt ${session.launch_key}`;
532
+ tx(board, (db) => {
533
+ setRunSession(db, row.id, { close_error: error, close_requested: session.close_requested ?? now() });
534
+ if (session.close_error !== error) event(db, row.task_id, 'session_close_failed', { error, retry: true }, row.id);
535
+ });
536
+ report.undelivered.push([row.task_id, addressOf(session), error]);
537
+ }
538
+ }
539
+ if (!session?.pane || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
508
540
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
509
541
  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());
510
542
  // Persist retries even for historical ends that did not have a close_session action.
@@ -520,9 +552,11 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
520
552
  const observed = panes.get(machine);
521
553
  if (observed.error) throw new Error(observed.error);
522
554
  if (observed.live.has(session.pane)) {
555
+ const pendingNotice = view(board, (db) => db.prepare("SELECT id FROM supercode_effects WHERE json_extract(effect, '$.kind')='session_notice' AND json_extract(effect, '$.runId')=? LIMIT 1").get(row.id));
556
+ if (pendingNotice || session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been delivered: ${session.lifecycle_notice_error ?? 'pending'}`);
523
557
  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
558
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
525
- if (protectedNow) continue;
559
+ if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
526
560
  stopSession(board, session); observed.live.delete(session.pane);
527
561
  }
528
562
  } catch (e) { error = String(e.message).slice(-500); }
@@ -579,6 +613,31 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
579
613
  }
580
614
  }
581
615
 
616
+ /** The verb drains this durable notice immediately; the dispatcher retries after a failure. */
617
+ export async function sessionNotice(board, e) {
618
+ const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
619
+ if (prior?.close_confirmed_at) {
620
+ tx(board, (db) => {
621
+ event(db, e.id, 'session_notice_failed', { address: e.address, verb: e.verb, notice: e.durableId, error: 'session closure already confirmed; notice cannot be delivered', retry: false }, e.runId);
622
+ if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
623
+ });
624
+ return true;
625
+ }
626
+ e = { ...e, address: e.address ?? addressOf(prior) };
627
+ let error = null;
628
+ try {
629
+ if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
630
+ await send(boardAddress(board.label), board.label, e.address, e.text, `board-run-notice-${e.id}-${e.durableId}`, { queue: true }); }
631
+ catch (failure) { error = String(failure.message).slice(-500); }
632
+ tx(board, (db) => {
633
+ const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
634
+ if (session) setRunSession(db, e.runId, { ...session,
635
+ ...(error ? { lifecycle_notice_error: error } : { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
636
+ if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : 'session_notice_delivered', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, ...(error ? { error, retry: true } : {}) }, e.runId);
637
+ });
638
+ return !error;
639
+ }
640
+
582
641
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
583
642
  export async function sessionEffect(board, e) {
584
643
  if (e.kind === 'send_to_actor') {
@@ -671,6 +730,81 @@ export function paused(root) {
671
730
  const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
672
731
 
673
732
 
733
+ /** A transport error is not a negative launch receipt. The key remains owned by its run. */
734
+ function launchReceipt(board, session) {
735
+ const r = sc(['teams', 'rpc', 'launches.get', JSON.stringify({ key: session.launch_key }), '--machine', session.machine],
736
+ { timeout: 30_000, as: boardAddress(board.label) });
737
+ return r.code === 0 ? lastJson(r.stdout) : null;
738
+ }
739
+
740
+ function bindLaunch(board, row, session, pane, sid) {
741
+ return tx(board, (db) => {
742
+ const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
743
+ const saved = json(current?.metadata, {})?.supercode;
744
+ if (!saved || saved.launch_key !== session.launch_key || (saved.session_id && sid && saved.session_id !== sid)) return false;
745
+ const address = sid ? `sc:${session.machine}:${session.harness}:${sid}` : null;
746
+ setRunSession(db, row.id, { ...saved, pane, session_id: sid ?? saved.session_id,
747
+ ...(address ? { address, launch_pending: false, launch_error: null, seen_at: now() } : {}) });
748
+ if (current.ended_at) requestSessionClose(db, row.task_id, { runId: row.id });
749
+ else if (address && getTask(db, row.task_id)?.current_run_id === row.id) {
750
+ subscribeIn(db, row.task_id, address, 'worker');
751
+ for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id=?').all(row.task_id)) subscribeIn(db, p.parent_id, address, 'dependency');
752
+ for (const t of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of=?').all(row.task_id)) subscribeIn(db, t.task_id, address, 'worker');
753
+ }
754
+ if (sid && saved.launch_pending) event(db, row.task_id, 'session_launch_reconciled', { key: session.launch_key, pane, session_id: sid, role: saved.role }, row.id);
755
+ return true;
756
+ });
757
+ }
758
+
759
+ /** A fast worker can reach its first board door before open's reply. Bind only the daemon's keyed pane. */
760
+ export function reconcileCallerLaunch(board, sid) {
761
+ const rows = view(board, (db) => db.prepare(`SELECT r.id, r.task_id, r.metadata FROM task_runs r
762
+ JOIN tasks t ON t.current_run_id=r.id WHERE r.ended_at IS NULL
763
+ AND json_extract(r.metadata, '$.supercode.launch_pending') = 1`).all());
764
+ const machine = localMachine();
765
+ const harness = sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code';
766
+ const candidates = rows.filter((row) => {
767
+ const s = json(row.metadata, {})?.supercode;
768
+ return s?.machine === machine && s.harness === harness && (!s.session_id || s.session_id === sid);
769
+ });
770
+ if (!candidates.length) return;
771
+ const pane = process.env.SUPERCODE_TEAMS_PANE || observeFleet().sessions?.find((s) => s.address === `sc:${machine}:${harness}:${sid}`)?.pane;
772
+ if (!pane) return;
773
+ for (const row of candidates) {
774
+ const s = json(row.metadata, {})?.supercode;
775
+ const receipt = launchReceipt(board, s);
776
+ if (receipt?.live && receipt.pane === pane) {
777
+ if (bindLaunch(board, row, s, pane, sid)) return;
778
+ }
779
+ }
780
+ }
781
+
782
+ function reconcileLaunches(board, fleet, report, entry, effects, settings) {
783
+ const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
784
+ WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
785
+ AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
786
+ for (const row of rows) {
787
+ const s = json(row.metadata, {})?.supercode;
788
+ try {
789
+ const receipt = launchReceipt(board, s);
790
+ if (!receipt?.pane) { report.queued.push([row.task_id, s.launch_error ?? `awaiting launch receipt ${s.launch_key}`]); continue; }
791
+ const sid = s.session_id ?? fleet.sessions?.find((f) => f.pane === receipt.pane && String(f.address).startsWith(`sc:${s.machine}:${s.harness}:`))?.address.split(':')[3];
792
+ bindLaunch(board, row, s, receipt.pane, receipt.live ? sid : null);
793
+ if (receipt.live === false && !row.ended_at && now() - s.launched_at > settings.grace) {
794
+ // A recorded pane absent on its machine is evidence; a closed response stream was not.
795
+ if (!panePresent(s.machine, receipt.pane)) {
796
+ const moved = tx(board, (db) => {
797
+ if (getTask(db, row.task_id)?.current_run_id !== row.id) return null;
798
+ setRunSession(db, row.id, { launch_pending: false });
799
+ return fire(db, board, entry, row.task_id, 'spawn_failed', { roles: ['platform'], payload: { error: `launch ${s.launch_key} ended before session binding` } });
800
+ });
801
+ if (moved) effects.push(...moved.effects);
802
+ }
803
+ } else if (!sid) report.queued.push([row.task_id, `pane ${receipt.pane} is live; awaiting ${s.harness} session identity`]);
804
+ } catch (error) { report.failed.push([row.task_id, `launch reconciliation retained: ${error.message}`]); }
805
+ }
806
+ }
807
+
674
808
  function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
675
809
  const machine = normalMachine(task.machine);
676
810
  if (machine === localMachine() && !(task.workspace_kind === 'dir' && task.workspace_path && existsSync(task.workspace_path))) {
@@ -691,7 +825,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
691
825
  closeRequested(board, { undelivered: [] }, { taskId: task.id });
692
826
  const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
693
827
  .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
694
- .find((r) => r.session?.pane && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
828
+ .find((r) => (r.session?.pane || r.session?.launch_key) && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
695
829
  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'}`);
696
830
  let prev = view(board, (db) => lastSession(db, task.id, slot));
697
831
  if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
@@ -711,15 +845,30 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
711
845
  // a pane that could not be closed still holds the session: no second process is started on it
712
846
  if (prev?.close_requested && !prev.closed_at) throw new Error(`the slot's session ${prev.session_id} asked to be closed and its pane ${prev.pane} could not be: ${report.undelivered.map((u) => u[2]).join('; ') || 'no reason given'}`);
713
847
  }
714
- // Record the pane before waiting for the harness to name itself. A failed launch still owns a process.
715
- const rememberPane = (pane, sessionId, mode) => {
716
- if (!pane) return;
848
+ const openOwned = (key, sid, mode, args) => {
849
+ tx(board, (db) => setRunSession(db, runId, { ...meta, surface: 'pane', machine, harness, slot,
850
+ session_id: sid, mode, launch_key: key, launch_pending: true, launched_at: now() }));
851
+ const result = sc(args, { timeout: 180_000, as: boardAddress(board.label) });
852
+ const facts = launchReceipt(board, { machine, launch_key: key });
853
+ const pane = facts?.pane ?? lastJson(result.stdout)?.pane ?? null;
854
+ // The daemon's keyed receipt is authoritative even when the stream carrying open's reply closed.
855
+ if (pane) tx(board, (db) => {
856
+ const before = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode ?? {};
857
+ setRunSession(db, runId, { ...before, pane });
858
+ });
859
+ const currentFleet = observeFleet();
860
+ const discovered = sid ?? currentFleet.sessions?.find((f) => f.pane === pane && String(f.address).startsWith(`sc:${machine}:${harness}:`))?.address.split(':')[3];
861
+ if (pane && discovered && facts?.live === true) {
862
+ return { surface: 'pane', session_id: discovered, machine, harness, pane, mode, launch_key: key,
863
+ launch_pending: false, launch_error: null, launched_at: now() };
864
+ }
865
+ const reason = facts?.live === false && pane ? `launch pane ${pane} ended` :
866
+ `launch ${key} is awaiting its machine receipt or session identity: ${(result.stderr || result.stdout).trim().slice(-300)}`;
717
867
  tx(board, (db) => {
718
- const row = db.prepare('SELECT metadata, ended_at FROM task_runs WHERE id = ?').get(runId);
719
- const before = json(row?.metadata, {})?.supercode ?? {};
720
- setRunSession(db, runId, { ...before, ...meta, surface: 'pane', session_id: sessionId, harness, machine, pane, slot, mode, launched_at: now() });
721
- if (row?.ended_at) requestSessionClose(db, task.id, { runId });
868
+ setRunSession(db, runId, { launch_pending: true, launch_error: reason });
869
+ event(db, task.id, 'session_launch_pending', { key, pane, reason }, runId);
722
870
  });
871
+ throw Object.assign(new Error(reason), { launchPending: true });
723
872
  };
724
873
  let session = null;
725
874
  let replaced = null;
@@ -742,20 +891,27 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
742
891
  const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, ...kept } = prev;
743
892
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
744
893
  // a kept session is told what this run is for (its card came back to it, with what happened since)
745
- if (input) deliverNow(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`);
894
+ tx(board, (db) => setRunSession(db, runId, { ...session, ...meta }));
895
+ if (input) {
896
+ try { deliverNow(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
897
+ catch (error) {
898
+ tx(board, (db) => {
899
+ const effect = { kind: 'session_notice', id: task.id, runId, address: addressOf(prev), text: input, verb: 'start' };
900
+ const saved = db.prepare('INSERT INTO supercode_effects(effect) VALUES(?)').run(JSON.stringify(effect));
901
+ setRunSession(db, runId, { lifecycle_notice_pending: Number(saved.lastInsertRowid), lifecycle_notice_error: error.message });
902
+ event(db, task.id, 'session_notice_failed', { verb: 'start', error: error.message, retry: true }, runId);
903
+ });
904
+ }
905
+ }
746
906
  } else if (prev.resumable !== false) {
747
907
  // a resumed session is told what this run is for, as a kept one is: the run's own prompt (a reviewer resumed for
748
908
  // its next round gets that round's prompt, its arc's context with it), not only that it was resumed
749
909
  const told = input ? `The board resumed this session for this run of card ${task.id}.\n\n${input}` : `The board resumed this session after it was lost; you are still on card ${task.id}: ${task.title}. Carry on.`;
750
910
  // a session is resumed in its own harness, whatever the lane's is now
751
911
  const its = HARNESSES[prev.harness ?? 'claude-code'] ?? HARNESSES['claude-code'];
752
- const r = sc(['open', '--new', its.program, ...base.slice(3), '--key', `board-${task.id}-r${runId}-resume`, '--input', told, '--', ...its.resume(prev.session_id), ...tail], { timeout: 180_000, as: boardAddress(board.label) });
753
- const pane = r.code === 0 ? lastJson(r.stdout)?.pane ?? null : null;
754
- rememberPane(pane, prev.session_id, 'resume');
755
- // a resume the harness refuses (its transcript is gone) ends the pane at once; another machine's launch
756
- // answers before that shows, so the pane is looked for again a moment later
757
- if (pane && paneSurvives(task.machine, pane)) session = { surface: 'pane', session_id: prev.session_id, harness: prev.harness ?? 'claude-code', machine, pane, mode: 'resume', launched_at: now() };
758
- else replaced = { session_id: prev.session_id, why: r.code === 0 ? `its resume ended as soon as it started (pane ${pane})` : (r.stderr || r.stdout).trim().slice(-300) };
912
+ const launchKey = `board-${task.id}-r${runId}-resume`;
913
+ session = openOwned(launchKey, prev.session_id, 'resume', ['open', '--new', its.program, ...base.slice(3),
914
+ '--key', launchKey, '--input', told, '--', ...its.resume(prev.session_id), ...tail]);
759
915
  } else replaced = { session_id: prev.session_id, why: 'its resume never came up' };
760
916
  } else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
761
917
  // a card keeps its context: a slot's session that cannot be kept or resumed stops the launch, and only a session the
@@ -766,29 +922,11 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
766
922
  throw failed;
767
923
  }
768
924
  if (!session) {
769
- let sid = how.namesItself ? null : randomUUID();
770
- const launchKey=`board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
771
- const r = sc([...base, '--key', launchKey, '--input', input ?? `Card ${task.id}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...tail], { timeout: 180_000, as: boardAddress(board.label) });
772
- if (r.code !== 0) throw new Error(`supercode open --on ${task.machine}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
773
- const pane = lastJson(r.stdout)?.pane ?? null;
774
- rememberPane(pane, sid, 'fresh');
775
- const receipt=sc(['teams','rpc','launches.get',JSON.stringify({key:launchKey}),'--machine',task.machine],{timeout:30_000,as:boardAddress(board.label)});
776
- const facts=receipt.code===0?lastJson(receipt.stdout):null;
777
- if (!pane || facts?.inputDelivered===false || facts?.live===false || (!how.namesItself && !paneSurvives(task.machine,pane))) {
778
- // The failed run's durable close queue owns its pane, even if the close door is unavailable.
779
- throw new Error(`${harness} launch did not become a working session: ${facts?.inputError??(!pane?'no pane returned':'pane ended or initial input was not delivered')}`);
780
- }
781
- // a harness that names its own session is found in the fleet by the pane the board opened for it
782
- for (let waited = 0; !sid && pane && waited < 90_000; waited += 3000) {
783
- pause(3000);
784
- sid = observeFleet().sessions?.find((f) => f.pane === pane && String(f.address).split(':')[2] === harness)?.address.split(':')[3] ?? null;
785
- }
786
- if (!sid) {
787
- // a pane whose session the board cannot name is closed, never left working beside the next start's
788
- // The run retains its pane for cleanup even though no harness session id arrived.
789
- throw new Error(`${harness} in pane ${pane ?? '(none)'} on ${task.machine} named no session within 90 s; the pane is closed`);
790
- }
791
- session = { surface: 'pane', session_id: sid, machine, pane, mode: 'fresh', launched_at: now(), ...(replaced ? { replaces: replaced.session_id } : {}) };
925
+ const sid = how.namesItself ? null : randomUUID();
926
+ const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
927
+ session = openOwned(launchKey, sid, 'fresh', [...base, '--key', launchKey,
928
+ '--input', input ?? `Card ${task.id}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...tail]);
929
+ if (replaced) session.replaces = replaced.session_id;
792
930
  }
793
931
  session.harness = session.harness ?? harness;
794
932
  session.address = `sc:${machine}:${session.harness}:${session.session_id}`;
@@ -812,7 +950,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
812
950
  for (const t of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of = ?').all(task.id)) subscribeIn(db, t.task_id, session.address, 'worker');
813
951
  return true;
814
952
  });
815
- if (!recorded) throw new Error(`run ${runId} ended while its session launched; its pane is queued for closure`);
953
+ if (!recorded) return { ...session, mode: 'finished_during_launch' };
816
954
  return session;
817
955
  }
818
956
 
@@ -965,7 +1103,9 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
965
1103
  function deliverNow(board, to, body, key) {
966
1104
  const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key,
967
1105
  body: `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)` };
968
- sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
1106
+ const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
1107
+ const answer = lastJson(r.stdout);
1108
+ if (answer?.code !== 0) throw new Error((answer?.text || r.stderr || `no delivery answer (exit ${r.code})`).trim().slice(-300));
969
1109
  }
970
1110
 
971
1111
  async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
@@ -1074,12 +1214,14 @@ async function notify(board, fleet, report, entry) {
1074
1214
  if (text) {
1075
1215
  // Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
1076
1216
  await send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
1217
+ tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id }));
1077
1218
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
1078
1219
  }
1079
1220
  cursor = ev.id;
1080
1221
  }
1081
1222
  } catch (error) {
1082
1223
  failed = String(error.message).slice(-200);
1224
+ tx(board, (db) => event(db, sub.task_id, 'notice_failed', { after_event: cursor, address: sub.chat_id, error: failed, retry: true }));
1083
1225
  report.undelivered.push([sub.task_id, sub.chat_id, failed]);
1084
1226
  }
1085
1227
  // the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
@@ -1362,7 +1504,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1362
1504
  readMailbox(board, boardAddress(board.label), report); phase('mailbox');
1363
1505
  await refreshRelationAnswers(board); phase('answers');
1364
1506
  const effects = [];
1365
- supervise(board, fleet, settings, report, entry, effects); closeRequested(board, report); phase('supervise');
1507
+ reconcileLaunches(board, fleet, report, entry, effects, settings); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1366
1508
  report.unsettled = [];
1367
1509
  report.promoted = recomputeReady(board, effects, report.unsettled);
1368
1510
  await sendBoardMessages(board, report);
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 { addDependency, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
15
+ import { addDependency, retireSupersededRuns, 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'];
@@ -106,7 +106,10 @@ export function currentRun(db, taskId) {
106
106
  export function setRunSession(db, runId, session) {
107
107
  const row = one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId);
108
108
  const whole = json(row?.metadata, {}) ?? {};
109
- whole.supercode = { ...whole.supercode, ...session };
109
+ const before = whole.supercode;
110
+ whole.supercode = { ...before, ...session };
111
+ const task = one(db, 'SELECT task_id FROM task_runs WHERE id = ?', runId);
112
+ if (task && session.session_id && session.pane && (before?.session_id !== session.session_id || before?.pane !== session.pane)) retireSupersededRuns(db, task.task_id, runId, whole.supercode);
110
113
  run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
111
114
  return whole;
112
115
  }
@@ -204,7 +207,8 @@ export function createCard(board, spec) {
204
207
  inheritSubscriptions(db, id, [arc.id], { workers: true });
205
208
  event(db, arc.id, 'subtask_added', { subtask: id, title });
206
209
  }
207
- if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
210
+ if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
211
+ else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
208
212
  return { id, existing: false };
209
213
  });
210
214
  }
@@ -247,6 +251,7 @@ export function claim(board, id, { ttl, lock = claimer(), expectStatus = 'ready'
247
251
 
248
252
  /** `claim` inside the caller's own transaction (a dispatcher's guard and claim see one state). */
249
253
  export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null } = {}) {
254
+ if (getTask(db, id)?.subtask_of) throw new Error('a subtask is worked by its arc and has no run of its own');
250
255
  // a triage card is claimed to be specified, which does not wait on its parents
251
256
  if (expectStatus !== 'triage' && !parentsDone(db, id)) {
252
257
  run(db, "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", id);
@@ -351,10 +356,19 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
351
356
  }
352
357
 
353
358
  /** The I/O a committed move asked for. */
354
- export async function runEffects(board, effects = []) {
355
- const pending = board.db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all();
356
- for (const e of [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects]) {
357
- if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
359
+ export async function runEffects(board, effects = [], { retryNotices = false } = {}) {
360
+ const requested = new Set(effects.map((e) => e.durableId).filter(Boolean));
361
+ const pending = board.db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all()
362
+ .filter((p) => retryNotices || requested.has(p.id) || json(p.effect, {})?.kind !== 'session_notice');
363
+ const durable = new Set(pending.map((p) => p.id));
364
+ const combined = [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects.filter((e) => !e.durableId || !durable.has(e.durableId))];
365
+ combined.sort((a,b) => Number(b.kind === 'session_notice') - Number(a.kind === 'session_notice'));
366
+ for (const e of combined) {
367
+ if (e.kind === 'session_notice') {
368
+ const { sessionNotice } = await import('./dispatch.mjs');
369
+ if (!await sessionNotice(board, e)) continue;
370
+ }
371
+ else if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
358
372
  else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
359
373
  else if (e.kind === 'stop_run') stopWorker(board, e.runId);
360
374
  else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
package/board/rows.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // tables, run fields and small indexing metadata. No application locks or snapshot rewrites.
3
3
  import { json } from './store.mjs';
4
4
  const quote = s => `"${s.replaceAll('"', '""')}"`;
5
- const RUNTIME = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at','current_run_id','consecutive_failures','last_failure_error','block_recurrences','session_id']);
5
+ const RUNTIME = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at','current_run_id','consecutive_failures','last_failure_error','block_recurrences','session_id','workspace_path']);
6
6
  const OMIT = new Set(['tasks','supercode_cards','task_links','sqlite_sequence']);
7
7
  export class RuntimeRows {
8
8
  constructor(db) { this.db=db; this.revision=null; this.tables=new Map(); this.runtime={}; this.clocks={}; this.cachedTasks={}; this.cachedCards={}; }
@@ -301,6 +301,31 @@ export function transitionsFor(workflow, status, name) {
301
301
  return [...(workflow.verbs?.[name] ?? []).filter((t) => !t.from?.length || t.from.includes(status)), ...(workflow.statuses[status]?.on?.[name] ?? []), ...(workflow.on?.[name] ?? [])];
302
302
  }
303
303
 
304
+ function noticeRun(db, task) {
305
+ return task.current_run_id ? one(db, 'SELECT id, metadata FROM task_runs WHERE id = ?', task.current_run_id)
306
+ : all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? ORDER BY id DESC', task.id).find((r) => json(r.metadata, {})?.supercode?.session_id) ?? null;
307
+ }
308
+
309
+ function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects) {
310
+ const after = cardOf(db, task.id), to = statusOf(db, after);
311
+ const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
312
+ const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
313
+ const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
314
+ if (!beforeRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === beforeRun.id)) return;
315
+ const session = json(beforeRun.metadata, {})?.supercode;
316
+ const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
317
+ if (!address && !session?.launch_key && !session?.pane) return;
318
+ const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
319
+ const text = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
320
+ const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
321
+ const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name };
322
+ const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
323
+ effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
324
+ const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', beforeRun.id)?.metadata, {}) ?? {};
325
+ current.supercode = { ...current.supercode, lifecycle_notice_pending: Number(inserted.lastInsertRowid) };
326
+ run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(current), beforeRun.id);
327
+ }
328
+
304
329
  /**
305
330
  * Apply event `name` to card `id` in the caller's transaction. `roles` are who sends it (a transition with `by`
306
331
  * takes only a sender in one of its roles; `platform` for the dispatcher's own observations). Answers
@@ -311,6 +336,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
311
336
  const { workflow, params } = entry;
312
337
  if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return fireRun(db, board, entry, id, name, { roles, payload, extra });
313
338
  const task = cardOf(db, id);
339
+ const beforeRun = noticeRun(db, task);
314
340
  const from = statusOf(db, task);
315
341
  if (!workflow.statuses[from]) throw new Error(`${id} is ${from}, which the board's workflow does not declare`);
316
342
  const candidates = transitionsFor(workflow, from, name);
@@ -328,6 +354,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
328
354
  const to = take(db, board, entry, task, from, taken, scope, effects);
329
355
  settle(db, board, entry, id, effects);
330
356
  projectStatus(db, board, entry, id);
357
+ queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects);
331
358
  return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
332
359
  }
333
360
 
@@ -358,7 +385,7 @@ function cardOf(db, id) {
358
385
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
359
386
  if (!row) throw new Error(`no card ${id}`);
360
387
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
361
- return { ...row, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
388
+ return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
362
389
  }
363
390
 
364
391
  // one transition: the old status's exit actions, the move, the transition's actions, the new status's entry actions
@@ -367,6 +394,7 @@ function take(db, board, entry, task, from, transition, scope, effects) {
367
394
  const target = transition.target ? String(templateValue(transition.target, scope) ?? '') : null;
368
395
  if (target && !workflow.statuses[target]) throw new Error(`the workflow moves ${task.id} to ${target}, which it does not declare`);
369
396
  const moves = target && target !== from;
397
+ if (moves && scope.card.paused) throw new Error('a paused card must be resumed before its stage can change');
370
398
  const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from, to: target ?? from, effects, runId: task.current_run_id ?? null };
371
399
  if (moves) for (const a of workflow.statuses[from].exit ?? []) act(ctx, a);
372
400
  if (moves) setStatus(db, workflow, task.id, target);
@@ -590,6 +618,7 @@ const ACTIONS = {
590
618
  const t = now();
591
619
  if (row?.current_run_id) {
592
620
  const runId = Number(row.current_run_id);
621
+ retireSupersededRuns(ctx.db, ctx.id, runId, json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode);
593
622
  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
594
623
  WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
595
624
  requestSessionClose(ctx.db, ctx.id, { runId });
@@ -715,6 +744,23 @@ const ACTIONS = {
715
744
  send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
716
745
  };
717
746
 
747
+ /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
748
+ export function retireSupersededRuns(db, id, runId, session) {
749
+ if (!session?.session_id && !session?.pane) return;
750
+ for (const old of all(db, `SELECT id, task_id, metadata FROM task_runs WHERE id < ? AND ended_at IS NULL
751
+ AND json_extract(metadata, '$.supercode.machine') IS ?
752
+ AND ((? IS NOT NULL AND json_extract(metadata, '$.supercode.session_id') = ?)
753
+ OR (? IS NOT NULL AND json_extract(metadata, '$.supercode.pane') = ?))`,
754
+ runId, session.machine ?? null, session.session_id ?? null, session.session_id ?? null, session.pane ?? null, session.pane ?? null)) {
755
+ const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
756
+ if (!s || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
757
+ 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}`);
758
+ if (s.pane && s.pane === session.pane) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
759
+ 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);
760
+ record(db, old.task_id, 'run_superseded', { successor: runId, session_id: s.session_id, pane: s.pane }, old.id);
761
+ }
762
+ }
763
+
718
764
  /** The create door uses the instance's canonical dependency representation. */
719
765
  export function addDependency(db, entry, id, parent) {
720
766
  if (entry.workflow.relations?.blocked_by) ACTIONS.relation({ db, id }, { add: parent, reason: 'declared dependency' });
@@ -727,7 +773,7 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
727
773
  if ((runId != null && row.id !== runId) || (through != null && row.id > through)) continue;
728
774
  const whole = json(row.metadata, {}) ?? {};
729
775
  const session = whole.supercode;
730
- if (!session?.pane || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
776
+ if ((!session?.pane && !session?.launch_key) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
731
777
  if (session.close_requested && !session.closed_at) continue;
732
778
  whole.supercode = { ...session, close_requested: session.close_requested ?? now() };
733
779
  delete whole.supercode.closed_at; // older versions recorded success without checking the receipt
@@ -828,6 +874,7 @@ export function fireRun(db, board, entry, id, name, { roles = [], payload = {},
828
874
  if (outcome) take(db, board, entry, cardOf(db, id), statusOf(db, cardOf(db, id)), outcome, after, effects);
829
875
  }
830
876
  projectStatus(db, board, entry, id);
877
+ queueTransitionNotice(db, board, entry, task, status, name, payload, row, effects);
831
878
  return { from: status, to: statusOf(db, cardOf(db, id)), effects };
832
879
  }
833
880
 
package/board/ztrack.mjs CHANGED
@@ -14,7 +14,7 @@ const hash = (text) => createHash('sha256').update(text).digest('hex');
14
14
  const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
15
15
  const quote = (s) => `"${s.replaceAll('"', '""')}"`;
16
16
  const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
17
- const RUNTIME = new Set(['claim_lock', 'claim_expires', 'worker_pid', 'last_heartbeat_at', 'current_run_id', 'consecutive_failures', 'last_failure_error', 'block_recurrences', 'session_id']);
17
+ const RUNTIME = new Set(['claim_lock', 'claim_expires', 'worker_pid', 'last_heartbeat_at', 'current_run_id', 'consecutive_failures', 'last_failure_error', 'block_recurrences', 'session_id', 'workspace_path']);
18
18
  const CARD_MAIN = new Set(['id', 'title', 'body', 'status', 'assignee', 'priority', 'workspace_kind', 'workspace_path', 'branch_name']);
19
19
  const EPHEMERAL = new Set(['tasks', 'supercode_cards', 'task_links', 'sqlite_sequence']);
20
20
  const atomic = (path, text) => { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, path); };
@@ -128,7 +128,11 @@ class ZtrackBoard {
128
128
  const changes = this.rows.changes(this.mem, { all: !this.rows.normalized() });
129
129
  const canonical = (row, omitted) => row && Object.fromEntries(Object.entries(row).filter(([key])=>!omitted.has(key)));
130
130
  const documentChanges = changes.filter(({source})=>{
131
- if(source.name==='tasks')return JSON.stringify(canonical(source.row,new Set([...RUNTIME,'status'])))!==JSON.stringify(canonical(this.rows.cachedTasks[JSON.parse(source.key)[0]],new Set([...RUNTIME,'status'])));
131
+ if(source.name==='tasks') {
132
+ const before=this.rows.cachedTasks[JSON.parse(source.key)[0]];
133
+ if(source.row?.workspace_kind!=='scratch' && source.row?.workspace_path!==before?.workspace_path)return true;
134
+ return JSON.stringify(canonical(source.row,new Set([...RUNTIME,'status'])))!==JSON.stringify(canonical(before,new Set([...RUNTIME,'status'])));
135
+ }
132
136
  if(source.name==='supercode_cards')return JSON.stringify(canonical(source.row,new Set(['since','after_fired','next_run_at'])))!==JSON.stringify(canonical(this.rows.cachedCards[JSON.parse(source.key)[0]],new Set(['since','after_fired','next_run_at'])));
133
137
  return false;
134
138
  });
@@ -370,7 +374,7 @@ class ZtrackBoard {
370
374
  const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
371
375
  this.insert('tasks', { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
372
376
  status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
373
- workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: workspace?.[2] ?? null, branch_name: card.branch ?? null });
377
+ workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: workspace?.[1] === 'scratch' ? (this.saved.runtime?.[id]?.workspace_path ?? meta.workspace_path ?? null) : workspace?.[2] ?? null, branch_name: card.branch ?? null });
374
378
  this.insert('supercode_cards', { ...this.saved.clocks?.[id], task_id: id, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
375
379
  blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
376
380
  paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null });
@@ -419,7 +423,7 @@ class ZtrackBoard {
419
423
  const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
420
424
  if (c.archived) metadata.archived = true;
421
425
  if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
422
- cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_path ? `:${row.workspace_path}` : ''),
426
+ cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && row.workspace_path ? `:${row.workspace_path}` : ''),
423
427
  branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
424
428
  relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: this.fileIds.get(b.id) ?? b.id })), acceptanceCriteria: [] });
425
429
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.107",
3
+ "version": "0.4.109",
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": {