@volter/supercode-orchestrator 0.5.157 → 0.5.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/cli.mjs CHANGED
@@ -26,7 +26,7 @@ import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
26
26
  import { basename, dirname, join, resolve } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { homedir, tmpdir, userInfo } from 'node:os';
29
- import { actorOf, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
29
+ import { actorOf, CALLER_RUN_ADDRESS, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
30
30
  import { isLocalWorkspace } from './workspace.mjs';
31
31
  import { openZtrackBoard } from './ztrack.mjs';
32
32
  import * as engine from './engine.mjs';
@@ -357,7 +357,7 @@ export async function runVerb(argv) {
357
357
  await readAgentMains(root);
358
358
  const sid = listing && !args.mine ? null : callerSession();
359
359
  // what this command records is the calling session's act (its address, as its subscriptions name it)
360
- current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare("SELECT json_extract(metadata, '$.supercode.address') AS address FROM task_runs WHERE json_extract(metadata, '$.supercode.session_id') = ? ORDER BY id DESC LIMIT 1").get(sid)?.address) ?? null : null;
360
+ current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
361
361
  if (sid) setActor(callerAddress());
362
362
  setPrincipal(await callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
363
363
  // the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
@@ -1590,22 +1590,18 @@ async function answerVerbs(root, log) {
1590
1590
 
1591
1591
  /** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. A
1592
1592
  * verb that takes more than a second is a slow line of its own, with the steps that ended while it ran (its own and,
1593
- * in this one process, any other call's that held it) and what the process used while it ran (`process`): its
1594
- * processor time beside the line's `ms` says whether the time was computed or waited for, with the major page faults
1595
- * and the waits (voluntary context switches) and preemptions (involuntary) the kernel counted. */
1593
+ * in this one process, any other call's that held it) and what the whole process used over the verb's wall time
1594
+ * (`whole_process`, slow-log.mjs processUse: every thread and every other call in flight, never the verb alone): its
1595
+ * processor time beside the line's `ms` says whether the time was computed or waited for, and the block reads and
1596
+ * major page faults say on what. */
1596
1597
  async function answerOne(root, line) {
1597
1598
  const began = performance.now();
1598
- const before = process.resourceUsage();
1599
+ const before = slowLog.processCounters?.() ?? null;
1599
1600
  let named = null, by = null;
1600
1601
  try { return await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); }
1601
1602
  finally {
1602
1603
  const ms = performance.now() - began;
1603
- if (named && ms > slowLog.SLOW_MS) {
1604
- const after = process.resourceUsage();
1605
- const used = { cpu_ms: Math.round((after.userCPUTime + after.systemCPUTime - before.userCPUTime - before.systemCPUTime) / 1000), major_faults: after.majorPageFault - before.majorPageFault,
1606
- waits: after.voluntaryContextSwitches - before.voluntaryContextSwitches, preempted: after.involuntaryContextSwitches - before.involuntaryContextSwitches };
1607
- recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, slowest: null, extra: { caller: by, steps: slowLog.stepsWithin?.(began, performance.now()) ?? null, process: used } });
1608
- }
1604
+ if (named && ms > slowLog.SLOW_MS) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, slowest: null, extra: { caller: by, steps: slowLog.stepsWithin?.(began, performance.now()) ?? null, whole_process: before ? slowLog.processUse(before) : null } });
1609
1605
  }
1610
1606
  }
1611
1607
 
@@ -0,0 +1,70 @@
1
+ // The earlier runtime store beside a board (`ztrack.json.runtime.db`), read for the one thing the board still asks of
2
+ // it after the import: whether the board's store holds its runs (ADR 0003).
3
+ //
4
+ // That store is a WAL file nobody keeps open, so every open of it is a first open: the opener builds the WAL's index
5
+ // under an exclusive lock, and a second process opening in the same moment is refused its first statement with
6
+ // SQLITE_BUSY_RECOVERY ("database is locked"), which SQLite does not wait out. Read-only changes nothing about that.
7
+ // Asked on every board open, two followers opening the board on each pass met there and one of them exited (114
8
+ // times in seven days on one install). So the earlier store's count is read once per process for that store as it
9
+ // stands, and the read is tried again, for a bounded time, when another process is in it.
10
+ //
11
+ // Only the earlier store's half of the answer is kept. The board store's runs are counted on every open, as they
12
+ // always were: its owner keeps it open and writes it through its WAL, so no stamp of its files says its rows are
13
+ // unchanged, and a store that lost runs or was replaced must be refused by the very next open.
14
+ import { statSync } from 'node:fs';
15
+ import { DatabaseSync } from 'node:sqlite';
16
+
17
+ // ctime as well as mtime: a file rewritten in place with its mtime put back keeps size and mtime, never ctime
18
+ const stamp = (path, { empty = true } = {}) => { try { const s = statSync(path); return !empty && !s.size ? '' : `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { return null; } };
19
+ // The earlier store as it stands: its file, and its WAL while that holds anything (a write to a WAL file changes the
20
+ // WAL, not always the file; an empty WAL is made and removed by readers).
21
+ const standing = (legacy) => `${stamp(legacy)}|${stamp(`${legacy}-wal`, { empty: false }) ?? ''}`;
22
+ const counted = new Map(); // an earlier store's path → { as: the stamps it was counted under, runs }
23
+
24
+ /** How long a read that keeps meeting another process is tried again before it fails. The meeting this is for (two
25
+ * first opens of the earlier store) lasts as long as building a WAL index: of 631 collided opens measured in two
26
+ * processes that did nothing else, the longest took 20 ms and 4 tries. One second is fifty times that and no longer
27
+ * than a slow line's own bound; a lock held longer is not that meeting, and the opener says so and fails instead of
28
+ * holding its process on it. */
29
+ export const CONTENTION_BOUND_MS = 1000;
30
+
31
+ /** How many runs the earlier store holds (0 when it keeps no runtime rows). */
32
+ function runsOf(legacy) {
33
+ const old = new DatabaseSync(legacy, { readOnly: true });
34
+ try {
35
+ if (!old.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runtime_rows'").get()) return 0;
36
+ return old.prepare("SELECT COUNT(*) AS n FROM runtime_rows WHERE name = 'task_runs' AND value IS NOT NULL").get().n;
37
+ } finally { old.close(); }
38
+ }
39
+
40
+ /** `fn`, tried again after `wait(attempt)` while it meets another process (`contended(error)`), for at most
41
+ * CONTENTION_BOUND_MS: then it fails as SQLite failed it (same codes), naming the file and the wait. */
42
+ function bounded(path, fn, { contended, wait }) {
43
+ const began = performance.now();
44
+ for (let attempt = 0; ; attempt++) {
45
+ try { return fn(); }
46
+ catch (error) {
47
+ if (!contended(error)) throw error;
48
+ const waited = Math.round(performance.now() - began);
49
+ if (waited >= CONTENTION_BOUND_MS) {
50
+ throw Object.assign(new Error(`database is locked: ${path} was held by another process for ${waited} ms over ${attempt + 1} tries (${error.message}, errcode ${error.errcode})`, { cause: error }), { code: error.code, errcode: error.errcode, errstr: error.errstr });
51
+ }
52
+ wait(attempt);
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Whether the board store at `file` holds fewer runs than the earlier store `legacy` beside it: `{ here, runs }` when
59
+ * it does (it was created without the import), null when it does not or no earlier store is there. `countHere` counts
60
+ * the board store's runs, on every call. The earlier store's count is read once per process while its file and WAL
61
+ * stand unchanged. A read that meets another process is tried again within CONTENTION_BOUND_MS (`bounded`).
62
+ */
63
+ export function unimported(file, legacy, countHere, retry) {
64
+ if (stamp(legacy) === null) return null;
65
+ const here = bounded(file, countHere, retry);
66
+ const as = standing(legacy);
67
+ let known = counted.get(legacy);
68
+ if (known?.as !== as) { known = { as, runs: bounded(legacy, () => runsOf(legacy), retry) }; counted.set(legacy, known); }
69
+ return here < known.runs ? { here, runs: known.runs } : null;
70
+ }
package/board/store.mjs CHANGED
@@ -143,6 +143,14 @@ export function listBoards(root) {
143
143
  return out;
144
144
  }
145
145
 
146
+ // The run a session's own verb is recorded against, by the session the dispatcher wrote in the run's metadata: asked on
147
+ // every verb a session runs (cli.mjs). The lookup and its index are written from the same two expressions, because
148
+ // SQLite uses an expression index only for a statement that writes the expression the same way.
149
+ const RUN_SESSION = "json_extract(metadata, '$.supercode.session_id')";
150
+ const RUN_ADDRESS = "json_extract(metadata, '$.supercode.address')";
151
+ export const CALLER_RUN_ADDRESS = `SELECT ${RUN_ADDRESS} AS address FROM task_runs WHERE ${RUN_SESSION} = ? ORDER BY id DESC LIMIT 1`;
152
+ const RUN_SESSION_INDEX = 'idx_runs_supercode_session';
153
+
146
154
  /** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
147
155
  export function layOut(db, { fresh }) {
148
156
  if (fresh) db.exec(SCHEMA);
@@ -154,6 +162,24 @@ export function layOut(db, { fresh }) {
154
162
  // each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
155
163
  // any statement is prepared; no statement names it, so a store without it is only slower.
156
164
  db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
165
+ // The calling session's run (CALLER_RUN_ADDRESS): without this index each lookup read every run's whole metadata,
166
+ // overflow pages included. `id` keeps a session's runs in the order the lookup asks for, and the address makes the
167
+ // index answer the lookup without reading the run. No statement names it, so a store without it is only slower, and a
168
+ // release that does not know it keeps it current like any other index. Its first creation reads `task_runs` once.
169
+ // A run whose metadata SQLite cannot read as JSON refuses the creation: the board opens without the index, says which
170
+ // rows to look for, and tries again on its next open. Once the index is there, SQLite refuses a write of such
171
+ // metadata to a run.
172
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'task_runs'").get()) {
173
+ try { db.exec(`CREATE INDEX IF NOT EXISTS ${RUN_SESSION_INDEX} ON task_runs(${RUN_SESSION}, id, ${RUN_ADDRESS})`); }
174
+ catch (error) {
175
+ if (contention(error)) throw error;
176
+ const said = current().said;
177
+ if (!said.has(RUN_SESSION_INDEX)) {
178
+ said.add(RUN_SESSION_INDEX);
179
+ current().err(`board: warning: index ${RUN_SESSION_INDEX} on task_runs was not created (${error.message}); the board is open without it and each session's verb reads every run. The runs that refuse it: SELECT id FROM task_runs WHERE metadata IS NOT NULL AND NOT json_valid(metadata, 6)\n`);
180
+ }
181
+ }
182
+ }
157
183
  const effectColumns = new Set(db.prepare('PRAGMA table_info(supercode_effects)').all().map((c) => c.name));
158
184
  for (const [column, type] of Object.entries(EFFECT_COLUMNS)) if (!effectColumns.has(column)) db.exec(`ALTER TABLE supercode_effects ADD COLUMN ${column} ${type}`);
159
185
  // what changed is marked by the store itself, whoever writes it (published.mjs)
package/board/ztrack.mjs CHANGED
@@ -16,6 +16,7 @@ import { basename, dirname, join, resolve } from 'node:path';
16
16
  import { DatabaseSync } from 'node:sqlite';
17
17
  import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES } from './store.mjs';
18
18
  import { GitDocument } from './git-document.mjs';
19
+ import { unimported } from './legacy-store.mjs';
19
20
  import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
20
21
  import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
21
22
 
@@ -23,8 +24,11 @@ import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
23
24
  const timedStep = (step, fn) => { const started = performance.now(); try { return fn(); } finally { noteStep(step, performance.now() - started); } };
24
25
  // One statement that takes this long is a step of its own, named by its text: a verb that only reads (`show`) runs in
25
26
  // no transaction and has no other step, so a hold inside it was a slow line that named no statement (8.7 s on
26
- // 2026-10-11, where every statement of `show` measures under 80 ms on a copy of the same store).
27
- const SLOW_STATEMENT_MS = 100;
27
+ // 2026-10-11, where every statement of `show` measures under 80 ms on a copy of the same store). Every native call on
28
+ // the board's connection is timed this way (ZtrackBoard.statement, the engine's `exec`), the backing's own included:
29
+ // the reads of `board_state` and `board_errors` that come before each of the engine's statements, and a statement's
30
+ // compilation, which reads the schema.
31
+ const SLOW_STATEMENT_MS = 50;
28
32
  const timedStatement = (sql, fn) => {
29
33
  const started = performance.now();
30
34
  try { return fn(); }
@@ -137,13 +141,13 @@ class ZtrackBoard {
137
141
  boardErrors: self.errors,
138
142
  prepare(sql) {
139
143
  return {
140
- get: (...args) => self.read(() => timedStatement(sql, () => self.statement(sql).get(...args))),
141
- all: (...args) => self.read(() => timedStatement(sql, () => self.statement(sql).all(...args))),
142
- iterate: (...args) => self.read(() => timedStatement(sql, () => self.statement(sql).all(...args)))[Symbol.iterator](),
143
- run: (...args) => self.active ? self.noting(() => timedStatement(sql, () => self.statement(sql).run(...args))) : self.tx(() => timedStatement(sql, () => self.statement(sql).run(...args))),
144
+ get: (...args) => self.read(() => self.statement(sql).get(...args)),
145
+ all: (...args) => self.read(() => self.statement(sql).all(...args)),
146
+ iterate: (...args) => self.read(() => self.statement(sql).all(...args))[Symbol.iterator](),
147
+ run: (...args) => self.active ? self.noting(() => self.statement(sql).run(...args)) : self.tx(() => self.statement(sql).run(...args)),
144
148
  };
145
149
  },
146
- exec(sql) { return self.active ? self.noting(() => self.conn.exec(sql)) : self.tx(() => self.conn.exec(sql)); },
150
+ exec(sql) { const run = () => timedStatement(sql, () => self.conn.exec(sql)); return self.active ? self.noting(run) : self.tx(run); },
147
151
  close() { self.close(); },
148
152
  };
149
153
  // A board's first open in this process recovers what its store accepted and derives what git owes for it (tx), so
@@ -179,7 +183,11 @@ class ZtrackBoard {
179
183
 
180
184
  statement(sql) {
181
185
  let statement = this.statements.get(sql);
182
- if (!statement) { statement = this.conn.prepare(sql); this.statements.set(sql, statement); }
186
+ if (!statement) {
187
+ const prepared = timedStatement(`prepare: ${sql}`, () => this.conn.prepare(sql));
188
+ statement = { get: (...args) => timedStatement(sql, () => prepared.get(...args)), all: (...args) => timedStatement(sql, () => prepared.all(...args)), run: (...args) => timedStatement(sql, () => prepared.run(...args)) };
189
+ this.statements.set(sql, statement);
190
+ }
183
191
  return statement;
184
192
  }
185
193
  one(sql, ...args) { return this.statement(sql).get(...args); }
@@ -229,18 +237,14 @@ class ZtrackBoard {
229
237
  // one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
230
238
  // running card). The board refuses it, so nothing runs on a board without its history.
231
239
  refuseUnimported(legacy) {
232
- if (!existsSync(legacy)) return;
233
- const here = this.conn.prepare('SELECT COUNT(*) AS n FROM main.task_runs').get().n;
234
- let runs = 0;
235
- const old = new DatabaseSync(legacy, { readOnly: true });
236
- try {
237
- if (old.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runtime_rows'").get()) {
238
- runs = old.prepare("SELECT COUNT(*) AS n FROM runtime_rows WHERE name = 'task_runs' AND value IS NOT NULL").get().n;
239
- }
240
- } finally { old.close(); }
241
- if (here >= runs) return;
240
+ // the earlier store's count is read once per process while that store stands unchanged, and a read that meets
241
+ // another process is tried again for a bounded time (legacy-store.mjs): the earlier store is opened by nobody
242
+ // else, so two openers at once refuse each other. This store's own runs are counted on every open.
243
+ const short = unimported(this.file, legacy, () => this.conn.prepare('SELECT COUNT(*) AS n FROM main.task_runs').get().n,
244
+ { contended: sqliteContention, wait: (attempt) => timedStep('board open: earlier store contention backoff', () => backoff(attempt)) });
245
+ if (!short) return;
242
246
  this.conn.close();
243
- throw new Error(`${this.file} holds ${here} runs, but ${legacy} holds ${runs}: it was created without importing them. Move it aside (keep it; writes made to it are not in the earlier store) and run any board command to import.`);
247
+ throw new Error(`${this.file} holds ${short.here} runs, but ${legacy} holds ${short.runs}: it was created without importing them. Move it aside (keep it; writes made to it are not in the earlier store) and run any board command to import.`);
244
248
  }
245
249
 
246
250
  // A board created from the previous runtime store imports its rows once; the old file stays as it was.
@@ -352,7 +356,8 @@ class ZtrackBoard {
352
356
  if (this.active) return this.noting(fn);
353
357
  this.observeForRead();
354
358
  for (let attempt = 0; ; attempt++) {
355
- try { return fn(); } catch (error) { if (!sqliteContention(error)) throw error; backoff(attempt); }
359
+ // each sleep between tries is a step: its count on a slow line is how many times the read was tried again
360
+ try { return fn(); } catch (error) { if (!sqliteContention(error)) throw error; timedStep('board read: contention backoff', () => backoff(attempt)); }
356
361
  }
357
362
  }
358
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.157",
3
+ "version": "0.5.159",
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,7 +24,7 @@
24
24
  "check-types": "node scripts/generate-types.mjs --check"
25
25
  },
26
26
  "dependencies": {
27
- "@volter/supercode-harness-sdk": "^0.3.96",
27
+ "@volter/supercode-harness-sdk": "^0.3.97",
28
28
  "@volter/teams": "0.2.65",
29
29
  "@volter/ztrack": "^2.4.23",
30
30
  "yaml": "^2.9.0"