@volter/supercode-orchestrator 0.4.88 → 0.4.90

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/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 { claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
- import { eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
15
+ import { refreshCardInputs, 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'];
@@ -321,6 +321,7 @@ export function setCaller(fn) { callerRoles = fn; }
321
321
 
322
322
  export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
323
323
  const entry = await loadWorkflow(board.root);
324
+ refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
324
325
  const result = tx(board, (db) => {
325
326
  roles ??= callerRoles(db, id);
326
327
  const r = fire(db, board, entry, id, name, { roles, payload, extra });
@@ -337,10 +338,12 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
337
338
  /** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
338
339
  export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
339
340
  const entry = await loadWorkflow(board.root);
340
- const { name, refusal, from } = tx(board, (db) => {
341
+ refreshCardInputs(board, id, { commits: true });
342
+ const { name, refusal, from } = (() => {
343
+ const db=board.db;
341
344
  roles ??= callerRoles(db, id);
342
345
  return { ...eventTo(db, board, entry, id, status, { roles, payload }), from: statusOf(db, getTask(db, id)) };
343
- });
346
+ })();
344
347
  if (from === status) return { from, to: status, moved: false, event: null };
345
348
  if (!name) throw new Error(refusal ?? `${id} is ${from}; no event you may send takes it to ${status}`);
346
349
  return { ...(await act(board, id, name, { roles, payload })), event: name };
@@ -348,7 +351,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
348
351
 
349
352
  /** The I/O a committed move asked for. */
350
353
  export async function runEffects(board, effects = []) {
351
- const pending = tx(board, (db) => db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all());
354
+ const pending = board.db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all();
352
355
  for (const e of [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects]) {
353
356
  if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
354
357
  else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
package/board/rows.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // Normalized runtime rows. Card text remains in the document; this stores only runtime
2
+ // tables, run fields and small indexing metadata. No application locks or snapshot rewrites.
3
+ import { json } from './store.mjs';
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']);
6
+ const OMIT = new Set(['tasks','supercode_cards','task_links','sqlite_sequence']);
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={}; }
9
+ normalized() { return Boolean(this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='runtime_rows'").get()); }
10
+ watch(mem) {
11
+ mem.exec('CREATE TEMP TABLE IF NOT EXISTS _runtime_dirty (name TEXT, key TEXT, PRIMARY KEY(name,key))');
12
+ for (const {name,sql} of mem.prepare("SELECT name,sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
13
+ const columns=mem.prepare(`PRAGMA table_info(${quote(name)})`).all();
14
+ const keys=columns.filter(c=>c.pk).sort((a,b)=>a.pk-b.pk).map(c=>c.name);
15
+ if (!keys.length) keys.push('rowid');
16
+ this.tables.set(name,{name,sql,keys});
17
+ const key=prefix=>`json_array(${keys.map(k=>`${prefix}.${quote(k)}`).join(',')})`;
18
+ for(const [suffix,verb,prefix] of [['i','INSERT','NEW'],['u','UPDATE','NEW'],['d','DELETE','OLD']])
19
+ mem.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(`_runtime_${name}_${suffix}`)} AFTER ${verb} ON main.${quote(name)} BEGIN INSERT OR IGNORE INTO _runtime_dirty VALUES ('${name}',${key(prefix)}); END`);
20
+ }
21
+ mem.exec('DELETE FROM _runtime_dirty');
22
+ }
23
+ insert(mem,name,row,key=null) {
24
+ if(row===null) {
25
+ const table=this.tables.get(name);if(!table)return;
26
+ mem.prepare(`DELETE FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key));return;
27
+ }
28
+ const keys=Object.keys(row);
29
+ mem.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote).join(',')}) VALUES (${keys.map(()=>'?').join(',')})`).run(...keys.map(k=>row[k]??null));
30
+ }
31
+ load(mem,{fresh=false}={}) {
32
+ if(!this.normalized())return false;
33
+ if(fresh) {
34
+ for(const row of this.db.prepare('SELECT name,sql FROM runtime_schema').all()) mem.exec(row.sql.replace('CREATE TABLE ','CREATE TABLE IF NOT EXISTS '));
35
+ this.watch(mem);
36
+ }
37
+ const query=this.revision===null?'SELECT * FROM runtime_rows':'SELECT * FROM runtime_rows WHERE revision>?';
38
+ const rows=this.revision===null?this.db.prepare(query).all():this.db.prepare(query).all(this.revision);
39
+ for(const row of rows) {
40
+ const value=json(row.value,null);
41
+ if(row.name==='@runtime') { if(value)this.runtime[row.key]=value;else delete this.runtime[row.key]; }
42
+ else if(row.name==='@clock') { if(value)this.clocks[row.key]=value;else delete this.clocks[row.key]; }
43
+ else if(row.name==='@task_cache') { if(value)this.cachedTasks[row.key]=value;else delete this.cachedTasks[row.key]; }
44
+ else if(row.name==='@card_cache') { if(value)this.cachedCards[row.key]=value;else delete this.cachedCards[row.key]; }
45
+ else this.insert(mem,row.name,value,row.key);
46
+ }
47
+ this.revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0);
48
+ return true;
49
+ }
50
+ changes(mem,{all=false}={}) {
51
+ 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 * FROM _runtime_dirty').all();
52
+ return dirty.map(({name,key})=>{
53
+ const table=this.tables.get(name);
54
+ const row=mem.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key))??null;
55
+ 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}};
56
+ if(name==='supercode_cards')return {name:'@clock',key:JSON.parse(key)[0],value:row&&{since:row.since,after_fired:row.after_fired,next_run_at:row.next_run_at},source:{name,key,row}};
57
+ return {name,key,value:row,source:{name,key,row}};
58
+ });
59
+ }
60
+ write(changes) {
61
+ this.db.exec('CREATE TABLE IF NOT EXISTS runtime_schema(name TEXT PRIMARY KEY,sql TEXT NOT NULL); CREATE TABLE IF NOT EXISTS runtime_rows(name TEXT,key TEXT,value TEXT,revision INTEGER NOT NULL,PRIMARY KEY(name,key)); CREATE INDEX IF NOT EXISTS runtime_rows_revision ON runtime_rows(revision)');
62
+ const revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0)+1;
63
+ const statement=this.db.prepare('INSERT INTO runtime_rows VALUES(?,?,?,?) ON CONFLICT(name,key) DO UPDATE SET value=excluded.value,revision=excluded.revision');
64
+ for(const {name,key,value,source} of changes) {
65
+ if (OMIT.has(name)) continue;
66
+ const encoded=value===null?null:JSON.stringify(value);
67
+ if (this.db.prepare('SELECT value FROM runtime_rows WHERE name=? AND key=?').get(name,key)?.value !== encoded) statement.run(name,key,encoded,revision);
68
+ if(name==='@runtime' || name==='@clock') {
69
+ const cacheName=name==='@runtime'?'@task_cache':'@card_cache';
70
+ const cache=name==='@runtime'?this.cachedTasks:this.cachedCards;
71
+ const encodedCache=source?.row?JSON.stringify(source.row):null;
72
+ if (this.db.prepare('SELECT value FROM runtime_rows WHERE name=? AND key=?').get(cacheName,key)?.value !== encodedCache) statement.run(cacheName,key,encodedCache,revision);
73
+ if(source?.row)cache[key]=source.row;else delete cache[key];
74
+ }
75
+ if(name==='@runtime') { if(value)this.runtime[key]=value;else delete this.runtime[key]; }
76
+ if(name==='@clock') { if(value)this.clocks[key]=value;else delete this.clocks[key]; }
77
+ }
78
+ 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);
79
+ this.db.prepare("INSERT INTO state VALUES('rows_revision',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(revision));
80
+ this.revision=revision;
81
+ }
82
+ }
@@ -80,7 +80,7 @@ function pattern(p) {
80
80
  // the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
81
81
  // pushes land on the shared remote-tracking refs), or null (asked only when an expression does)
82
82
  const commitTimes = new Map();
83
- function lastCommitAt(path, id) {
83
+ function observeLastCommit(path, id) {
84
84
  if (!path) return null;
85
85
  const key = `${path}:${id}`;
86
86
  const cached = commitTimes.get(key);
@@ -108,6 +108,18 @@ function lastCommitAt(path, id) {
108
108
  return value;
109
109
  }
110
110
 
111
+ function lastCommitAt(path, id) { return path ? commitTimes.get(`${path}:${id}`)?.value ?? null : null; }
112
+ /** Observe external facts before entering an optimistic board mutation. */
113
+ export function refreshCardInputs(board, id, { commits = false } = {}) {
114
+ const task=board.db.prepare('SELECT t.*,c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
115
+ if (!task)return;
116
+ if (commits) {
117
+ const own=task.workspace_kind!=='scratch'?task.workspace_path:null;
118
+ const parent=!own && task.subtask_of?board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of)?.workspace_path:null;
119
+ observeLastCommit(own??parent??null,id);
120
+ }
121
+ }
122
+
111
123
  // Mailbox I/O is prefetched by the dispatcher, outside every board write.
112
124
  const mailboxAnswers = new Map();
113
125
  const execFileAsync = promisify(execFile);
@@ -124,7 +136,7 @@ export async function refreshRelationAnswers(board) {
124
136
  const { stdout } = await execFileAsync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', '--replies', id, '--json'], { encoding: 'utf8', timeout: 20_000, maxBuffer: 1024 * 1024 });
125
137
  const rows = JSON.parse(stdout);
126
138
  if (!Array.isArray(rows)) throw new Error('message door returned no message list');
127
- mailboxAnswers.set(id, { answer: rows[0]?.replies?.[0]?.id ?? null });
139
+ mailboxAnswers.set(id, { answer: rows[0]?.replies?.[0]?.id ?? null, askedOf: rows[0]?.message?.session ?? null });
128
140
  } catch (error) { mailboxAnswers.set(id, { error: `the answer to ${id} could not be read: ${error.message.slice(0, 200)}` }); }
129
141
  }));
130
142
  }
@@ -173,14 +185,7 @@ function timeOf(db, task) {
173
185
  }
174
186
 
175
187
  /** The session message `id` was filed for (`supercode message show`): who was asked; or null. */
176
- function askedOf(id) {
177
- if (!id) return null;
178
- const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', id, '--json'], { encoding: 'utf8', timeout: 20_000 });
179
- let rows = null;
180
- try { rows = JSON.parse(r.stdout); } catch { /* read below */ }
181
- if (!Array.isArray(rows)) throw new Error(`who ${id} was sent to could not be read (supercode message show): ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim().slice(-200)}`);
182
- return rows[0]?.message?.session ?? null;
183
- }
188
+ function askedOf(id) { return mailboxAnswers.get(id)?.askedOf ?? null; }
184
189
 
185
190
  /** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
186
191
  export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}) {
@@ -300,6 +305,7 @@ export function transitionsFor(workflow, status, name) {
300
305
  * `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
301
306
  */
302
307
  export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
308
+ if (db.boardErrors?.has(id)) throw new Error(`${id}: ${db.boardErrors.get(id)}`);
303
309
  const { workflow, params } = entry;
304
310
  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 });
305
311
  const task = cardOf(db, id);
package/board/ztrack.mjs CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { createHash, randomBytes } from 'node:crypto';
5
5
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, resolve } from 'node:path';
7
- import { spawnSync } from 'node:child_process';
7
+ import { RuntimeRows } from './rows.mjs';
8
8
  import { DatabaseSync } from 'node:sqlite';
9
9
  import { actorOf, event, json, now, setActor } from './store.mjs';
10
10
  import { fire, projectStatus, statusOf, workflowFor } from './workflow.mjs';
@@ -40,16 +40,18 @@ class ZtrackBoard {
40
40
  this.preset = require('@volter/ztrack/kanban-preset');
41
41
  this.runtime = new DatabaseSync(`${marker}.runtime.db`);
42
42
  if (!this.runtime.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='state'").get()) this.runtime.exec('CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
43
- this.mem = null; this.active = false;
43
+ this.errors = new Map(); this.preserve = new Map();
44
+ this.mem = null; this.active = false; this.rows = new RuntimeRows(this.runtime);
44
45
  mkdirSync(dirname(this.path), { recursive: true });
45
46
  const self = this;
46
47
  this.db = {
48
+ boardErrors: self.errors,
47
49
  prepare(sql) { return Object.fromEntries(['get', 'all', 'run', 'iterate'].map((method) => [method, (...args) => self.access(() => method === 'iterate' ? self.mem.prepare(sql).all(...args)[Symbol.iterator]() : self.mem.prepare(sql)[method](...args), method !== 'run')])); },
48
50
  exec(sql) { return self.access(() => self.mem.exec(sql)); },
49
51
  close() { self.close(); },
50
52
  };
51
53
  if (seed) this.tx(() => {});
52
- else { this.load(); this.loadedStamp = this.stamp(); }
54
+ else this.readOnly(()=>{ this.load(); this.loadedStamp = this.stamp(); });
53
55
  }
54
56
  get(key, fallback = null) { return this.computing && this.pendingValues.has(key) ? this.pendingValues.get(key) : json(this.runtime.prepare('SELECT value FROM state WHERE key = ?').get(key)?.value, fallback); }
55
57
  put(key, value) { if (this.computing) { this.pendingValues.set(key, value); return; } this.runtime.prepare('INSERT INTO state VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value)); }
@@ -57,7 +59,9 @@ class ZtrackBoard {
57
59
  const fileStamp = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return 'missing'; } };
58
60
  return [fileStamp(this.path), fileStamp(this.archive), fileStamp(this.compatPath), this.get('version', '')].join('|');
59
61
  }
62
+ readOnly(fn) { for (;;) { try { return fn(); } catch(error) { if(!/busy|locked/i.test(error.message))throw error; } } }
60
63
  access(fn, reading = false) {
64
+ if (reading && !this.reading) { this.reading=true; try {return this.readOnly(()=>this.access(fn,true));}finally{this.reading=false;} }
61
65
  if (this.active || (reading && this.mem && this.loadedStamp === this.stamp())) return fn();
62
66
  if (reading) { this.load(); this.loadedStamp = this.stamp(); return fn(); }
63
67
  return this.tx(fn);
@@ -67,13 +71,33 @@ class ZtrackBoard {
67
71
  recover() {
68
72
  const pending = this.get('pending');
69
73
  if (!pending) return;
70
- for (const file of pending.files) {
71
- const current = hash(read(file.path));
72
- if (current !== file.before && current !== hash(file.text)) throw new Error(`unfinished board write conflicts with an edit to ${file.path}; retained runtime journal`);
73
- if (current === file.before) atomic(file.path, file.text);
74
+ const conflicts = new Set(), documentConflicts = new Set();
75
+ const observed = pending.files.map(file=>({...file,current:read(file.path)}));
76
+ for (const file of observed) {
77
+ const current=hash(file.current);
78
+ if(current===hash(file.text)||current===file.before)continue;
79
+ const ids=file.base!=null?this.codec.mergeBoardDocument(file.base,file.current,file.text,file.path).conflicts:this.codec.inspectBoardDocument(file.text,file.path).records.map(r=>r.id);
80
+ for(const id of ids){conflicts.add(nativeId(id));documentConflicts.add(id);}
81
+ }
82
+ for (const file of observed) {
83
+ const merged=file.base!=null?this.codec.mergeBoardDocument(file.base,file.current,file.text,file.path,[...documentConflicts]).text:(hash(file.current)===file.before?file.text:file.current);
84
+ if (hash(read(file.path))!==hash(file.current)) throw new Error('document changed during recovery; retry');
85
+ if (merged!==file.current)atomic(file.path,merged);
86
+ }
87
+ const changes = (pending.changes ?? []).filter(c => !conflicts.has(c.source?.row?.task_id ?? c.source?.row?.id ?? c.key));
88
+ if (pending.changes) { this.rows.write(changes); this.put('export_pending', [...this.get('export_pending', []), ...changes]); }
89
+ if (conflicts.size) {
90
+ const issues = this.get('recovery_conflicts', {});
91
+ for (const id of conflicts) issues[id] = {intent:pending.intent,reason:'A concurrent document edit superseded an unfinished operation on this card.'};
92
+ this.put('recovery_conflicts', issues);
93
+ const old = this.get('snapshot', {});
94
+ for (const id of conflicts) {
95
+ if (old.fingerprints?.[id]) pending.snapshot.fingerprints[id] = old.fingerprints[id];
96
+ else delete pending.snapshot.fingerprints?.[id];
97
+ }
74
98
  }
75
99
  this.put('snapshot', pending.snapshot);
76
- if (pending.intent) this.put(`applied:${pending.intent}`, true);
100
+ if (pending.intent) this.put(`applied:${pending.intent}`, {conflicts:[...conflicts]});
77
101
  this.put('version', randomBytes(8).toString('hex'));
78
102
  this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
79
103
  }
@@ -84,7 +108,7 @@ class ZtrackBoard {
84
108
 
85
109
  transaction(fn) {
86
110
  for (;;) {
87
- try { this.runtime.exec('BEGIN IMMEDIATE'); }
111
+ try { this.runtime.exec('BEGIN'); }
88
112
  catch (error) { if (!/busy|locked/i.test(error.message)) throw error; continue; }
89
113
  this.active = true;
90
114
  let committed = false;
@@ -98,48 +122,59 @@ class ZtrackBoard {
98
122
  if (this.seed) this.seedIntoMemory();
99
123
  else this.lift();
100
124
  const result = fn(this.db);
101
- const documents = this.documents();
102
- const files = [{ path: this.path, before: hash(this.text), text: documents.open }, { path: this.archive, before: hash(this.archiveText), text: documents.archived }];
125
+ const changes = this.rows.changes(this.mem, { all: !this.rows.normalized() });
126
+ const canonical = (row, omitted) => row && Object.fromEntries(Object.entries(row).filter(([key])=>!omitted.has(key)));
127
+ const cardDirty = this.seed || changes.some(({source})=>{
128
+ 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'])));
129
+ 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'])));
130
+ return source.name==='task_links';
131
+ });
132
+ const documents = cardDirty ? this.documents() : {open:this.text, archived:this.archiveText};
133
+ const files = [{ path: this.path, before: hash(this.text), base: this.text, text: documents.open }, { path: this.archive, before: hash(this.archiveText), base: this.archiveText, text: documents.archived }];
103
134
  const snapshot = this.snapshot();
104
135
  this.computing = false;
105
- this.runtime.exec('BEGIN IMMEDIATE');
136
+ this.runtime.exec('BEGIN');
106
137
  this.recover();
107
- if (this.get('version') !== version || files.some((f) => hash(read(f.path)) !== f.before)) { this.runtime.exec('ROLLBACK'); continue; }
138
+ if (this.get('version') !== version || files.some((f) => hash(read(f.path)) !== f.before)) { this.runtime.exec('ROLLBACK'); this.invalidate(); continue; }
108
139
  for (const [key, value] of this.pendingValues) this.put(key, value);
109
140
  // A durable intent precedes filesystem writes; recovery completes exactly this version.
110
141
  const intent = randomBytes(16).toString('hex');
111
- this.put('pending', { intent, files, snapshot });
142
+ this.put('pending', { intent, files, snapshot, changes });
112
143
  this.runtime.exec('COMMIT'); committed = true;
113
144
  // A concurrent writer may recover this intent and advance the runtime. Re-read
114
145
  // its identity before touching documents, retrying from any newer version.
115
- this.runtime.exec('BEGIN IMMEDIATE');
146
+ this.runtime.exec('BEGIN');
116
147
  if (this.get('pending')?.intent !== intent) {
117
148
  const applied = this.get(`applied:${intent}`, false);
118
149
  this.runtime.exec('ROLLBACK');
119
- if (applied) { this.loadedStamp = null; this.seed = null; return result; }
120
- continue;
150
+ if (applied) { this.loadedStamp = null; this.seed = null; if (applied.conflicts?.length) throw new Error(`Concurrent edits superseded this operation on ${applied.conflicts.join(', ')}`); return result; }
151
+ this.invalidate(); continue;
121
152
  }
122
153
  // Detect an editor racing the journal commit before replacing either document.
123
154
  if (files.some((f) => hash(read(f.path)) !== f.before)) {
124
155
  this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run(); this.runtime.exec('COMMIT');
125
- continue;
156
+ this.invalidate(); continue;
126
157
  }
127
158
  for (const f of files) if (hash(f.text) !== f.before) atomic(f.path, f.text);
159
+ this.rows.write(changes);
128
160
  this.put('snapshot', snapshot); this.put(`applied:${intent}`, true); this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
129
- this.export();
161
+ this.put('export_pending', [...this.get('export_pending', []), ...changes]);
130
162
  this.put('version', randomBytes(8).toString('hex'));
131
163
  this.runtime.exec('COMMIT');
164
+ try { this.flushExport(); } catch (error) { if (!/busy|locked|Hermes wrote/i.test(error.message)) throw error; }
132
165
  this.loadedStamp = this.stamp();
133
166
  this.seed = null;
134
167
  return result;
135
168
  } catch (error) {
136
169
  try { this.runtime.exec('ROLLBACK'); } catch { /* intent already durable */ }
137
- if (/busy|locked|Hermes wrote/i.test(error.message)) continue;
170
+ this.invalidate();
171
+ if (/busy|locked|Hermes wrote|document changed during recovery/i.test(error.message)) continue;
138
172
  if (committed) error.message += ' (the runtime journal retains any unfinished document write)';
139
173
  throw error;
140
174
  } finally { this.active = false; this.computing = false; this.pendingValues = null; }
141
175
  }
142
176
  }
177
+ invalidate() { this.mem?.close(); this.mem = null; this.rows = new RuntimeRows(this.runtime); this.loadedStamp = null; }
143
178
  seedIntoMemory() {
144
179
  const source = this.seed;
145
180
  for (const table of this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) this.mem.exec(`DELETE FROM ${quote(table.name)}`);
@@ -172,6 +207,7 @@ class ZtrackBoard {
172
207
  this.mem.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), task.current_run_id);
173
208
  }
174
209
  }
210
+ this.rows.watch(this.mem);
175
211
  this.put('hermes_cursor', Number(source.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
176
212
  }
177
213
  insert(table, row) {
@@ -181,15 +217,49 @@ class ZtrackBoard {
181
217
  this.mem.prepare(`INSERT OR REPLACE INTO ${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
182
218
  }
183
219
  load({ observe = false } = {}) {
184
- this.text = read(this.path); this.archiveText = read(this.archive);
185
- this.mem?.close(); this.mem = new DatabaseSync(':memory:'); this.init(this.mem, { fresh: true });
220
+ const text = read(this.path), archiveText = read(this.archive);
221
+ const fresh = !this.mem;
222
+ const changed = fresh || text !== this.text || archiveText !== this.archiveText;
223
+ this.text = text; this.archiveText = archiveText;
224
+ if (fresh) { this.mem = new DatabaseSync(':memory:'); this.init(this.mem, { fresh: true }); this.mem.boardErrors=this.errors; }
186
225
  this.saved = this.get('snapshot', { tables: {}, runtime: {}, clocks: {}, fingerprints: {} });
187
- for (const item of Object.values(this.saved.tables ?? {})) {
226
+ const normalized = this.rows.load(this.mem, { fresh });
227
+ if (normalized) { this.saved.runtime = this.rows.runtime; this.saved.clocks = this.rows.clocks; }
228
+ for (const item of Object.values(!normalized && fresh ? this.saved.tables ?? {} : {})) {
188
229
  if (item.sql) this.mem.exec(item.sql.replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '));
189
230
  for (const row of item.rows ?? []) this.insert(item.name, row);
190
231
  }
191
- const records = [...this.codec.parseBoardDocument(this.text, this.path), ...this.codec.parseBoardDocument(this.archiveText, this.archive)];
192
- const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban(records));
232
+ if (!fresh && !changed) {
233
+ for (const [id, row] of Object.entries(this.saved.runtime ?? {})) {
234
+ const keys = Object.keys(row);
235
+ if (keys.length) this.mem.prepare(`UPDATE tasks SET ${keys.map(k => `${quote(k)}=?`).join(',')} WHERE id=?`).run(...keys.map(k => row[k]), id);
236
+ }
237
+ for (const [id, row] of Object.entries(this.saved.clocks ?? {})) this.mem.prepare('UPDATE supercode_cards SET since=?,after_fired=?,next_run_at=? WHERE task_id=?').run(row.since??null,row.after_fired??null,row.next_run_at??null,id);
238
+ this.rows.watch(this.mem);
239
+ if (observe) this.documentEvents();
240
+ return;
241
+ }
242
+ this.rows.watch(this.mem);
243
+ this.mem.exec('DELETE FROM tasks; DELETE FROM supercode_cards; DELETE FROM task_links');
244
+ this.errors.clear(); this.preserve.clear();
245
+ const records = [];
246
+ for (const [text,path] of [[this.text,this.path],[this.archiveText,this.archive]]) {
247
+ const result = this.codec.inspectBoardDocument(text,path);
248
+ const kept = new Set(result.errors.map(e=>e.id));
249
+ for(const error of result.errors)this.errors.set(nativeId(error.id),error.reason);
250
+ for(const record of result.records) {
251
+ try { records.push(this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0]); }
252
+ catch(error) { this.errors.set(nativeId(record.id),error.message); kept.add(record.id); }
253
+ }
254
+ this.preserve.set(path,kept);
255
+ }
256
+ const counts=new Map(); for(const card of records)counts.set(nativeId(card.id),(counts.get(nativeId(card.id))??0)+1);
257
+ const parsed = {issues:records.filter(card=>{
258
+ const id=nativeId(card.id); if(counts.get(id)===1)return true;
259
+ this.errors.set(id,'Duplicate card identity across board documents');
260
+ for(const kept of this.preserve.values())kept.add(card.id);
261
+ return false;
262
+ })};
193
263
  this.originals = new Map(); this.fileIds = new Map(); this.taskKeys = new Map();
194
264
  for (const card of parsed.issues) {
195
265
  const id = nativeId(card.id);
@@ -209,6 +279,22 @@ class ZtrackBoard {
209
279
  this.loadCard(tid, taskCard, id);
210
280
  }
211
281
  }
282
+ // Keep the last readable projection only for the affected malformed cards.
283
+ // Their raw document spans remain untouched and their verbs refuse with this reason.
284
+ for (const [id] of this.errors) {
285
+ for (const [tid,row] of Object.entries(this.rows.cachedTasks)) {
286
+ const state=this.rows.cachedCards[tid];
287
+ if (tid!==id && state?.subtask_of!==id)continue;
288
+ this.insert('tasks',row); if(state)this.insert('supercode_cards',state);
289
+ if(tid!==id)this.errors.set(tid,`Parent ${id} has an invalid document edit`);
290
+ }
291
+ }
292
+ for (const [id,runtime] of Object.entries(this.saved.runtime??{})) {
293
+ if (!runtime.current_run_id || this.mem.prepare('SELECT 1 FROM tasks WHERE id=?').get(id))continue;
294
+ const task=this.rows.cachedTasks[id], state=this.rows.cachedCards[id];
295
+ if (task) {this.insert('tasks',task);if(state)this.insert('supercode_cards',state);}
296
+ this.errors.set(id,'Document removed a card with a live run; restore it and cancel its run through the workflow.');
297
+ }
212
298
  // Resolve local task refs only after all subtask identities are available.
213
299
  const taskIds = new Map([...this.taskKeys].map(([id, key]) => [key, id]));
214
300
  for (const row of this.mem.prepare('SELECT task_id, blocked_by FROM supercode_cards').all()) {
@@ -241,6 +327,7 @@ class ZtrackBoard {
241
327
  if (!Object.keys(previous).length) return;
242
328
  const by = this.committer();
243
329
  for (const [id, value] of Object.entries(current)) {
330
+ if (this.errors.has(id)) continue;
244
331
  const old = previous[id];
245
332
  if (old?.hash === value.hash) continue;
246
333
  let entry; try { entry = workflowFor(this.root); } catch {}
@@ -252,21 +339,20 @@ class ZtrackBoard {
252
339
  event(this.mem, id, kind, { by, source: 'document', status: value.status, assignee: value.assignee, author: by });
253
340
  if (!old && value.parent) event(this.mem, value.parent, 'subtask_added', { by, subtask: id, title: this.mem.prepare('SELECT title FROM tasks WHERE id = ?').get(id).title });
254
341
  }
255
- for (const id of Object.keys(previous)) if (!current[id]) {
256
- if (this.saved.runtime?.[id]?.current_run_id) throw new Error(`document removed ${id} with a live run; cancel it through its workflow first`);
342
+ for (const id of Object.keys(previous)) if (!current[id] && !this.errors.has(id)) {
343
+ if (this.saved.runtime?.[id]?.current_run_id) { this.errors.set(id,`Document removed a card with a live run; restore it and cancel it through its workflow first`); continue; }
257
344
  event(this.mem, id, 'removed', { by, source: 'document' });
258
345
  }
259
346
  }
260
347
  committer() {
261
- const r = spawnSync('git', ['-C', dirname(this.path), 'log', '-1', '--format=%an <%ae>', '--', this.path], { encoding: 'utf8', timeout: 5000 });
262
- return r.status === 0 && r.stdout.trim() ? `git:${r.stdout.trim()}` : 'document-editor';
348
+ return 'document-editor';
263
349
  }
264
350
  documents() {
265
351
  const rows = this.mem.prepare('SELECT * FROM tasks ORDER BY created_at, id').all();
266
352
  const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, c]));
267
353
  const byId = new Map(rows.map((r) => [r.id, r]));
268
354
  const cards = new Map();
269
- for (const row of rows.filter((r) => !states.get(r.id)?.subtask_of)) {
355
+ for (const row of rows.filter((r) => !states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
270
356
  const c = states.get(row.id) ?? {};
271
357
  const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: this.fileIds.get(row.id) ?? row.id, title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }])).issues[0];
272
358
  const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
@@ -284,7 +370,7 @@ class ZtrackBoard {
284
370
  if (key) { const set = occupied.get(parent) ?? new Set(); set.add(key); occupied.set(parent, set); }
285
371
  }
286
372
  const renderedKeys = new Set();
287
- for (const row of rows.filter((r) => states.get(r.id)?.subtask_of)) {
373
+ for (const row of rows.filter((r) => states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
288
374
  const c = states.get(row.id); const parent = cards.get(c.subtask_of);
289
375
  if (!parent) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
290
376
  const taken = occupied.get(c.subtask_of) ?? new Set(); occupied.set(c.subtask_of, taken);
@@ -306,17 +392,12 @@ class ZtrackBoard {
306
392
  const record = { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
307
393
  (states.get(id)?.archived || ['done', 'cancelled'].includes(card.status) ? archived : open).push(record);
308
394
  }
309
- return { open: this.codec.renderBoardDocument(this.text, open, this.path), archived: this.codec.renderBoardDocument(this.archiveText, archived, this.archive) };
395
+ return { open: this.codec.renderBoardDocument(this.text, open, this.path, [...(this.preserve.get(this.path)??[])]), archived: this.codec.renderBoardDocument(this.archiveText, archived, this.archive, [...(this.preserve.get(this.archive)??[])]) };
310
396
  }
311
397
  snapshot() {
312
- const tables = {};
313
- for (const table of this.mem.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
314
- if (!EPHEMERAL.has(table.name)) tables[table.name] = { ...table, rows: this.mem.prepare(`SELECT * FROM ${quote(table.name)}`).all() };
315
- }
316
- const runtime = Object.fromEntries(this.mem.prepare('SELECT * FROM tasks').all().map((t) => [t.id, Object.fromEntries(Object.entries(t).filter(([k]) => RUNTIME.has(k)))]));
317
- const clocks = Object.fromEntries(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, { since: c.since, after_fired: c.after_fired, next_run_at: c.next_run_at }]));
318
- return { tables, runtime, clocks, fingerprints: this.fingerprints(), taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
398
+ return { fingerprints: this.fingerprints(), taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
319
399
  }
400
+
320
401
  lift() {
321
402
  if (!existsSync(this.compatPath)) return;
322
403
  let entry; try { entry = workflowFor(this.root); } catch { return; }
@@ -331,6 +412,8 @@ class ZtrackBoard {
331
412
  if (payload.__supercode_export) continue;
332
413
  const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
333
414
  if (!task) continue;
415
+ this.mem.exec('SAVEPOINT hermes_card');
416
+ try {
334
417
  const roles = ['manager', 'reviewer', 'platform', 'hermes'];
335
418
  setActor(payload.by ?? 'hermes');
336
419
  let name = null; let words = { ...payload, reason: payload.reason ?? 'unblocked in Hermes' };
@@ -347,34 +430,56 @@ class ZtrackBoard {
347
430
  for (const effect of result.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
348
431
  }
349
432
  event(this.mem, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
433
+ this.mem.exec('RELEASE hermes_card');
434
+ } catch(error) {
435
+ this.mem.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
436
+ event(this.mem, task.id, 'hermes_refused', {by:'hermes',source_event:row.id,kind:row.kind,reason:error.message});
437
+ }
350
438
  }
351
439
  } finally { setActor(before); }
352
440
  if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
353
441
  } finally { compat.close(); }
354
442
  }
355
- export() {
443
+ flushExport() {
444
+ const changes = this.get('export_pending', []);
445
+ if (!changes.length) return;
446
+ this.export(changes);
447
+ // Remove only the prefix this export consumed; another writer may append meanwhile.
448
+ this.runtime.exec('BEGIN');
449
+ try {
450
+ const pending = this.get('export_pending', []);
451
+ if (JSON.stringify(pending.slice(0, changes.length)) === JSON.stringify(changes)) this.put('export_pending', pending.slice(changes.length));
452
+ this.runtime.exec('COMMIT');
453
+ } catch (error) { this.runtime.exec('ROLLBACK'); throw error; }
454
+ }
455
+ export(changes) {
356
456
  let entry; try { entry = workflowFor(this.root); } catch { return; }
357
457
  for (const { id } of this.mem.prepare('SELECT id FROM tasks').all()) projectStatus(this.mem, this.board(), entry, id);
358
458
  const compat = new DatabaseSync(this.compatPath);
359
- compat.exec('PRAGMA busy_timeout = 10000');
360
459
  try {
361
- compat.exec('BEGIN IMMEDIATE'); this.init(compat, { fresh: true });
460
+ compat.exec('BEGIN'); this.init(compat, { fresh: true });
362
461
  // Every import is event-based. Existing unconsumed acts must never be erased by an export race.
363
462
  const cursor = this.get('hermes_cursor', 0);
364
463
  const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
365
464
  if (unseen) throw new Error('Hermes wrote during board export; retained its events for the next read');
366
- const tables = this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
367
- for (const { name } of tables) {
368
- if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)) continue;
369
- compat.exec(`DELETE FROM ${quote(name)}`);
370
- for (const source of this.mem.prepare(`SELECT * FROM ${quote(name)}`).all()) {
371
- const row = { ...source };
372
- if (name === 'task_events') row.payload = JSON.stringify({ ...json(row.payload, {}), __supercode_export: true });
373
- const keys = Object.keys(row); compat.prepare(`INSERT INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => row[k]));
374
- }
465
+ const sources = new Map(changes.map(c => [`${c.source.name}:${c.source.key}`, c.source]));
466
+ for (const source of [...sources.values()]) {
467
+ const id = source.name === 'tasks' ? source.row?.id : source.name === 'supercode_cards' ? source.row?.task_id : null;
468
+ 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)});
469
+ }
470
+ for (const {name,key,row:source} of sources.values()) {
471
+ if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
472
+ const table = this.rows.tables.get(name);
473
+ if (!source) { compat.prepare(`DELETE FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key)); continue; }
474
+ const row = {...source};
475
+ if (name === 'task_events') row.payload = JSON.stringify({...json(row.payload,{}),__supercode_export:true});
476
+ const keys=Object.keys(row);
477
+ const previous=compat.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key));
478
+ if (previous && keys.every(k=>previous[k]===row[k]))continue;
479
+ compat.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(()=>'?')})`).run(...keys.map(k=>row[k]));
375
480
  }
376
481
  compat.exec('COMMIT');
377
- this.put('hermes_cursor', Number(compat.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
482
+ // Exported events carry an origin marker and never advance the Hermes input cursor.
378
483
  } catch (error) { try { compat.exec('ROLLBACK'); } catch {} throw error; }
379
484
  finally { compat.close(); }
380
485
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.88",
3
+ "version": "0.4.90",
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": {
@@ -24,9 +24,9 @@
24
24
  "check-types": "node scripts/generate-types.mjs --check"
25
25
  },
26
26
  "dependencies": {
27
- "yaml": "^2.9.0",
28
27
  "@volter/supercode-harness-sdk": "^0.3.41",
29
- "@volter/ztrack": "^2.4.14"
28
+ "@volter/ztrack": "^2.4.15",
29
+ "yaml": "^2.9.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@chat-adapter/state-memory": "4.39.0",