@volter/supercode-orchestrator 0.4.108 → 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 +2 -2
- package/board/dispatch.mjs +188 -46
- package/board/engine.mjs +18 -6
- package/board/workflow.mjs +49 -2
- package/board/ztrack.mjs +5 -1
- package/package.json +1 -1
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
|
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
-
|
|
507
|
-
if (
|
|
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)
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
770
|
-
const launchKey
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
|
@@ -353,10 +356,19 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
353
356
|
}
|
|
354
357
|
|
|
355
358
|
/** The I/O a committed move asked for. */
|
|
356
|
-
export async function runEffects(board, effects = []) {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
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);
|
|
360
372
|
else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
|
|
361
373
|
else if (e.kind === 'stop_run') stopWorker(board, e.runId);
|
|
362
374
|
else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
|
package/board/workflow.mjs
CHANGED
|
@@ -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
|
@@ -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')
|
|
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
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
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": {
|