@volter/supercode-orchestrator 0.4.141 → 0.4.143
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/dispatch.mjs +17 -6
- package/board/rows.mjs +17 -2
- package/board/workflow.mjs +22 -3
- package/board/ztrack.mjs +1 -0
- package/package.json +1 -1
package/board/dispatch.mjs
CHANGED
|
@@ -21,10 +21,10 @@ import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
|
21
21
|
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
22
22
|
import { hostname, tmpdir } from 'node:os';
|
|
23
23
|
import { basename, dirname, join } from 'node:path';
|
|
24
|
-
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
27
|
-
import { doors, fire, loadWorkflow, resumeOf, runSpec, requestSessionClose, retireSupersededRuns, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
|
|
27
|
+
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
|
|
28
28
|
import { checkWorkspace, assertWorkspaceOwner, resolveWorkspace } from './workspace.mjs';
|
|
29
29
|
import { evaluate, holds, render } from './expr.mjs';
|
|
30
30
|
import {
|
|
@@ -331,13 +331,13 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
331
331
|
return;
|
|
332
332
|
}
|
|
333
333
|
// a run past its runtime limit (the status's `run.limits.runtime`), as Hermes times its workers out
|
|
334
|
-
if (limits.runtime && runRow &&
|
|
334
|
+
if (limits.runtime && runRow && session?.wait_started_at == null && runWorkingSeconds(runRow, t) > Number(limits.runtime)) {
|
|
335
335
|
observe('timed_out', { limit_seconds: limits.runtime, error: `ran past max_runtime ${limits.runtime}s` }, 'timed_out');
|
|
336
336
|
return;
|
|
337
337
|
}
|
|
338
338
|
if (session?.surface === 'headless') {
|
|
339
339
|
// Hermes's stale check: a worker running past the limit with no heartbeat in the last hour is stopped
|
|
340
|
-
if (limits.stale && runRow &&
|
|
340
|
+
if (limits.stale && runRow && session?.wait_started_at == null && runWorkingSeconds(runRow, t) >= Number(limits.stale) && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
|
|
341
341
|
observe('stale', { timeout_seconds: limits.stale, pid: session.pid, error: `no heartbeat after ${t - Number(runRow.started_at)}s running` }, 'reclaimed');
|
|
342
342
|
return;
|
|
343
343
|
}
|
|
@@ -733,6 +733,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
733
733
|
if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
|
|
734
734
|
const runRow = currentRun(db, id);
|
|
735
735
|
const session = runRow?.metadata?.supercode;
|
|
736
|
+
if (session?.wait_started_at != null) return;
|
|
736
737
|
if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
|
|
737
738
|
if (session.surface === 'native' && fleet.native?.get(addressOf(session))?.alive !== true) return;
|
|
738
739
|
const live = fleet.sessions.find((x) => x.address === addressOf(session));
|
|
@@ -778,6 +779,12 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
778
779
|
}
|
|
779
780
|
}
|
|
780
781
|
|
|
782
|
+
function effectMessageKey(effect) {
|
|
783
|
+
if (effect.messageKey) return effect.messageKey;
|
|
784
|
+
const identity = JSON.stringify([effect.kind, effect.id, effect.runId, effect.sourceEvent, effect.durableId, effect.verb, effect.text]);
|
|
785
|
+
return `board-effect-${createHash('sha256').update(identity).digest('hex')}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
781
788
|
/** The verb drains this durable notice immediately; the dispatcher retries after a failure. */
|
|
782
789
|
export async function sessionNotice(board, e) {
|
|
783
790
|
const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
|
|
@@ -792,7 +799,7 @@ export async function sessionNotice(board, e) {
|
|
|
792
799
|
let error = null, receipt = null;
|
|
793
800
|
try {
|
|
794
801
|
if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
795
|
-
receipt = await send(boardAddress(board.label), board.label, e.address, e.text, e
|
|
802
|
+
receipt = await send(boardAddress(board.label), board.label, e.address, e.text, effectMessageKey(e));
|
|
796
803
|
if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
|
|
797
804
|
catch (failure) { error = String(failure.message).slice(-500); }
|
|
798
805
|
tx(board, (db) => {
|
|
@@ -809,11 +816,15 @@ export async function sessionEffect(board, e) {
|
|
|
809
816
|
if (e.kind === 'send_to_actor') {
|
|
810
817
|
const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
|
|
811
818
|
const session = json(row?.metadata, {})?.supercode;
|
|
819
|
+
if (session?.wait_started_at != null) {
|
|
820
|
+
tx(board, db => event(db, e.id, 'actor_notice_suppressed', { notice: e.durableId, reason: 'run is waiting', run_id: e.runId }, e.runId));
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
812
823
|
const address = addressOf(session);
|
|
813
824
|
let receipt;
|
|
814
825
|
try {
|
|
815
826
|
if (!address) throw new Error('actor session identity unavailable');
|
|
816
|
-
receipt = await send(boardAddress(board.label), board.label, address, e.text,
|
|
827
|
+
receipt = await send(boardAddress(board.label), board.label, address, e.text, effectMessageKey(e));
|
|
817
828
|
} catch (error) { receipt = { delivered: false, reason: error.message }; }
|
|
818
829
|
const kind = receipt.delivered ? 'actor_notice_delivered' : receipt.queued ? 'actor_notice_queued' : 'actor_notice_failed';
|
|
819
830
|
tx(board, db => {
|
package/board/rows.mjs
CHANGED
|
@@ -5,7 +5,7 @@ const quote = s => `"${s.replaceAll('"', '""')}"`;
|
|
|
5
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
|
-
constructor(db) { this.db=db; this.revision=null; this.tables=new Map(); this.runtime={}; this.clocks={}; this.cachedTasks={}; this.cachedCards={}; }
|
|
8
|
+
constructor(db) { this.db=db; this.revision=null; this.tables=new Map(); this.runtime={}; this.clocks={}; this.sequences={}; this.cachedTasks={}; this.cachedCards={}; }
|
|
9
9
|
normalized() { return Boolean(this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='runtime_rows'").get()); }
|
|
10
10
|
watch(mem) {
|
|
11
11
|
mem.exec('CREATE TEMP TABLE IF NOT EXISTS _runtime_dirty (name TEXT, key TEXT)');
|
|
@@ -40,16 +40,24 @@ export class RuntimeRows {
|
|
|
40
40
|
const value=json(row.value,null);
|
|
41
41
|
if(row.name==='@runtime') { if(value)this.runtime[row.key]=value;else delete this.runtime[row.key]; }
|
|
42
42
|
else if(row.name==='@clock') { if(value)this.clocks[row.key]=value;else delete this.clocks[row.key]; }
|
|
43
|
+
else if(row.name==='@sequence') { if(value)this.sequences[row.key]=value;else delete this.sequences[row.key]; }
|
|
43
44
|
else if(row.name==='@task_cache') { if(value)this.cachedTasks[row.key]=value;else delete this.cachedTasks[row.key]; }
|
|
44
45
|
else if(row.name==='@card_cache') { if(value)this.cachedCards[row.key]=value;else delete this.cachedCards[row.key]; }
|
|
45
46
|
else this.insert(mem,row.name,value,row.key);
|
|
46
47
|
}
|
|
48
|
+
if (mem.prepare("SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'").get()) {
|
|
49
|
+
for (const [name, value] of Object.entries(this.sequences)) {
|
|
50
|
+
const sequence = mem.prepare('SELECT seq FROM sqlite_sequence WHERE name=?').get(name);
|
|
51
|
+
if (sequence) mem.prepare('UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name=?').run(value.seq,name);
|
|
52
|
+
else mem.prepare('INSERT INTO sqlite_sequence(name,seq) VALUES(?,?)').run(name,value.seq);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
47
55
|
this.revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0);
|
|
48
56
|
return true;
|
|
49
57
|
}
|
|
50
58
|
changes(mem,{all=false}={}) {
|
|
51
59
|
const dirty=all?[...this.tables.values()].flatMap(t=>mem.prepare(`SELECT ${t.keys.map(quote).join(',')} FROM ${quote(t.name)}`).all().map(row=>({name:t.name,key:JSON.stringify(t.keys.map(k=>row[k]))}))):mem.prepare('SELECT DISTINCT name,key FROM _runtime_dirty').all();
|
|
52
|
-
|
|
60
|
+
const changes = dirty.map(({name,key})=>{
|
|
53
61
|
const table=this.tables.get(name);
|
|
54
62
|
const row=mem.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key))??null;
|
|
55
63
|
if(name==='tasks')return {name:'@runtime',key:JSON.parse(key)[0],value:row&&Object.fromEntries(Object.entries(row).filter(([k])=>RUNTIME.has(k))),source:{name,key,row}};
|
|
@@ -72,6 +80,12 @@ export class RuntimeRows {
|
|
|
72
80
|
if(name==='@clock')return JSON.stringify(source.row)!==JSON.stringify(this.cachedCards[key]??null);
|
|
73
81
|
return false;
|
|
74
82
|
});
|
|
83
|
+
if (mem.prepare("SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'").get()) {
|
|
84
|
+
for (const row of mem.prepare('SELECT name,seq FROM sqlite_sequence').all()) {
|
|
85
|
+
if (Number(this.sequences[row.name]?.seq) !== Number(row.seq)) changes.push({name:'@sequence',key:row.name,value:row,source:{name:'sqlite_sequence',key:JSON.stringify([row.name]),row}});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return changes;
|
|
75
89
|
}
|
|
76
90
|
mirror(changes, revision) {
|
|
77
91
|
const previous=Number(this.db.prepare("SELECT value FROM state WHERE key='read_revision'").get()?.value??-1);
|
|
@@ -108,6 +122,7 @@ export class RuntimeRows {
|
|
|
108
122
|
}
|
|
109
123
|
if(name==='@runtime') { if(value)this.runtime[key]=value;else delete this.runtime[key]; }
|
|
110
124
|
if(name==='@clock') { if(value)this.clocks[key]=value;else delete this.clocks[key]; }
|
|
125
|
+
if(name==='@sequence') { if(value)this.sequences[key]=value;else delete this.sequences[key]; }
|
|
111
126
|
}
|
|
112
127
|
for(const {name,sql} of this.tables.values())if(!OMIT.has(name))this.db.prepare('INSERT OR IGNORE INTO runtime_schema VALUES(?,?)').run(name,sql);
|
|
113
128
|
this.db.prepare("INSERT INTO state VALUES('rows_revision',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(revision));
|
package/board/workflow.mjs
CHANGED
|
@@ -186,13 +186,21 @@ function onlyTicks(was, body) {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/** Seconds a card's runs spent by role, and the rest of its life as `waiting`. */
|
|
189
|
+
export function runWorkingSeconds(row, at = now()) {
|
|
190
|
+
const metadata = typeof row.metadata === 'string' ? json(row.metadata, {}) : row.metadata;
|
|
191
|
+
const session = metadata?.supercode ?? {};
|
|
192
|
+
const end = Number(row.ended_at ?? at);
|
|
193
|
+
const waiting = Number(session.wait_seconds ?? 0) + (session.wait_started_at != null ? Math.max(0, end - Number(session.wait_started_at)) : 0);
|
|
194
|
+
return Math.max(0, end - Number(row.started_at ?? end) - waiting);
|
|
195
|
+
}
|
|
196
|
+
|
|
189
197
|
function timeOf(db, task) {
|
|
190
198
|
const t = now();
|
|
191
199
|
const out = { coding: 0, reviewing: 0, auditing: 0, triaging: 0 };
|
|
192
200
|
const key = { implementer: 'coding', reviewer: 'reviewing', auditor: 'auditing', triager: 'triaging' };
|
|
193
201
|
for (const r of all(db, 'SELECT started_at, ended_at, metadata FROM task_runs WHERE task_id = ?', task.id)) {
|
|
194
202
|
const role = json(r.metadata, {})?.supercode?.role ?? 'implementer';
|
|
195
|
-
const spent =
|
|
203
|
+
const spent = runWorkingSeconds(r, t);
|
|
196
204
|
const k = key[role] ?? role;
|
|
197
205
|
out[k] = (out[k] ?? 0) + spent;
|
|
198
206
|
}
|
|
@@ -338,7 +346,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
338
346
|
const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
|
|
339
347
|
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);
|
|
340
348
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
341
|
-
const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name };
|
|
349
|
+
const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
|
|
342
350
|
const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
343
351
|
effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
344
352
|
const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', beforeRun.id)?.metadata, {}) ?? {};
|
|
@@ -612,6 +620,17 @@ const ACTIONS = {
|
|
|
612
620
|
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
|
|
613
621
|
ctx.effects.push(...result.effects);
|
|
614
622
|
},
|
|
623
|
+
wait_run(ctx, waiting) {
|
|
624
|
+
if (typeof waiting !== 'boolean') throw new Error('wait_run requires a boolean');
|
|
625
|
+
const at = now();
|
|
626
|
+
runSession(ctx, session => {
|
|
627
|
+
if (waiting) return { ...session, wait_started_at: session.wait_started_at ?? at };
|
|
628
|
+
if (session.wait_started_at == null) return session;
|
|
629
|
+
return { ...session, wait_started_at: null,
|
|
630
|
+
wait_seconds: Number(session.wait_seconds ?? 0) + Math.max(0, at - Number(session.wait_started_at)),
|
|
631
|
+
goal_nudged_at: at };
|
|
632
|
+
});
|
|
633
|
+
},
|
|
615
634
|
record(ctx, args, scope) {
|
|
616
635
|
const kind = typeof args === 'string' ? args : args?.kind;
|
|
617
636
|
const given = typeof args === 'object' && args?.payload ? args.payload : null;
|
|
@@ -791,7 +810,7 @@ const ACTIONS = {
|
|
|
791
810
|
ctx.effects.push({ kind: 'board_message', id: ctx.id });
|
|
792
811
|
},
|
|
793
812
|
send_to_actor(ctx, text) {
|
|
794
|
-
const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text };
|
|
813
|
+
const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
|
|
795
814
|
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
796
815
|
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
797
816
|
},
|
package/board/ztrack.mjs
CHANGED
|
@@ -625,6 +625,7 @@ class ZtrackBoard {
|
|
|
625
625
|
if (id) sources.set(`tasks:${JSON.stringify([id])}`, {name:'tasks',key:JSON.stringify([id]),row:this.mem.prepare('SELECT * FROM tasks WHERE id=?').get(id)});
|
|
626
626
|
}
|
|
627
627
|
for (const {name,key,row:source} of sources.values()) {
|
|
628
|
+
if (name === 'sqlite_sequence') continue;
|
|
628
629
|
if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
|
|
629
630
|
const table = this.rows.tables.get(name);
|
|
630
631
|
if (name === 'task_events') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.143",
|
|
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": {
|