@volter/supercode-orchestrator 0.5.160 → 0.5.162

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.
@@ -1,31 +1,17 @@
1
1
  // Resident local consumer of the daemon's committed record rows. Disconnects
2
2
  // preserve the last applied cursor, but held data is stale until caught up.
3
3
  export class RecordView {
4
- constructor(open) {
4
+ constructor(open, { progressMs = 10_000 } = {}) {
5
5
  this.open = open; this.records = {}; this.positions = new Map();
6
6
  this.cursor = null; this.stream = null; this.waiters = new Set();
7
+ this.pending = [];
7
8
  this.ready = false; this.heardAt = 0;
9
+ this.progressMs = progressMs; this.progressTimer = null;
8
10
  }
9
11
  async read(timeoutMs) {
10
12
  const deadline = Date.now() + timeoutMs;
11
13
  if (this.stream && Date.now() - this.heardAt > 45_000) this.stream.close('its record stream went quiet');
12
- if (!this.stream && !this.opening) {
13
- this.opening = this.open({ after: this.cursor }).then((stream) => {
14
- this.stream = stream;
15
- this.ready = false;
16
- this.heardAt = Date.now();
17
- stream.on('data', (frame) => {
18
- try { this.apply(frame); }
19
- catch (error) { this.fail(error); stream.close(error.message); }
20
- });
21
- stream.on('close', (reason) => {
22
- if (this.stream !== stream) return;
23
- this.stream = null; this.ready = false;
24
- this.fail(new Error(reason ?? 'the local record stream closed'));
25
- });
26
- }).finally(() => { this.opening = null; });
27
- }
28
- if (this.opening) await this.opening;
14
+ if (!this.stream) await this.connect();
29
15
  if (this.ready) return this.records;
30
16
  if (!this.stream) throw this.failure ?? new Error('the local record stream ended before its snapshot');
31
17
  return new Promise((resolve, reject) => {
@@ -37,6 +23,44 @@ export class RecordView {
37
23
  this.waiters.add(waiter);
38
24
  });
39
25
  }
26
+ connect() {
27
+ if (this.stream) return Promise.resolve();
28
+ return this.opening ??= Promise.resolve().then(() => this.open({ after: this.cursor })).then((stream) => {
29
+ this.stream = stream; this.ready = false; this.heardAt = Date.now();
30
+ stream.on('data', (frame) => {
31
+ if (this.stream !== stream) return;
32
+ try { this.apply(frame); }
33
+ catch (error) { this.fail(error); stream.close(error.message); }
34
+ });
35
+ stream.on('receive-progress', ({ bytes, type }) => {
36
+ if (this.stream !== stream || bytes <= 0 || (type !== 'snapshot' && type !== 'changes')) return;
37
+ this.heardAt = Date.now(); this.watchProgress();
38
+ });
39
+ stream.on('close', (reason) => {
40
+ if (this.stream !== stream) return;
41
+ clearTimeout(this.progressTimer); this.progressTimer = null;
42
+ this.stream = null; this.ready = false; this.pending = [];
43
+ this.fail(new Error(reason ?? 'the local record stream closed'));
44
+ });
45
+ this.watchProgress();
46
+ }).finally(() => { this.opening = null; });
47
+ }
48
+ // Beats prove the door is alive, not that its snapshot or unfinished page is advancing. Record frames and bytes
49
+ // arriving inside them renew this deadline. Expiry reopens at the applied cursor, never its held prefix.
50
+ watchProgress() {
51
+ clearTimeout(this.progressTimer); this.progressTimer = null;
52
+ if (this.ready || !this.stream) return;
53
+ const stream = this.stream;
54
+ this.progressTimer = setTimeout(() => {
55
+ if (this.stream !== stream) return;
56
+ this.progressTimer = null; this.stream = null; this.ready = false; this.pending = [];
57
+ const error = new Error('the local record stream stopped making snapshot or cursor catch-up progress');
58
+ this.fail(error);
59
+ try { stream.close(error.message); } catch { /* the failed stream is already detached */ }
60
+ this.connect().catch((error) => this.fail(error));
61
+ }, this.progressMs);
62
+ this.progressTimer.unref?.();
63
+ }
40
64
  fail(error) {
41
65
  this.failure = error;
42
66
  for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(error); }
@@ -46,13 +70,21 @@ export class RecordView {
46
70
  apply(frame) {
47
71
  if (frame.type === 'beat') { this.heardAt = Date.now(); return; }
48
72
  if (frame.type === 'snapshot') {
49
- this.self = frame.machine; this.records = frame.records; this.positions.clear();
73
+ this.self = frame.machine; this.records = frame.records; this.positions.clear(); this.pending = [];
50
74
  for (const [machine, held] of Object.entries(this.records)) this.positions.set(machine, new Map((held.record?.sessions ?? []).map((row, at) => [row.address, at])));
51
75
  this.cursor = frame.cursor; this.ready = true;
52
76
  } else if (frame.type === 'changes') {
53
77
  if (!this.cursor || frame.cursor?.id !== this.cursor.id) throw new Error('record row changes name a different database; a new snapshot is required');
78
+ // Row-sized transport frames belong to one logical page. Neither the applied state nor its resumable cursor
79
+ // advances until that page's final frame arrives; a disconnect discards the unfinished page.
80
+ if (frame.partial) {
81
+ this.ready = false; this.pending.push(...(frame.changes ?? [])); this.heardAt = Date.now();
82
+ if (frame.changes?.length || !this.progressTimer) this.watchProgress();
83
+ return;
84
+ }
85
+ const changes = this.pending.concat(frame.changes ?? []); this.pending = [];
54
86
  const moved = new Set();
55
- for (const change of frame.changes ?? []) {
87
+ for (const change of changes) {
56
88
  if (change.sequence <= this.cursor.sequence || !this.own(change.key)) continue;
57
89
  const { machine, section, key, position } = change.key;
58
90
  if (change.topic === 'record-head') {
@@ -85,6 +117,7 @@ export class RecordView {
85
117
  this.cursor = frame.cursor; this.ready = !frame.more;
86
118
  } else throw new Error(`unrecognized record-view frame ${frame.type}`);
87
119
  this.heardAt = Date.now();
120
+ this.watchProgress();
88
121
  if (this.ready) {
89
122
  for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.resolve(this.records); }
90
123
  this.waiters.clear();
package/board/store.mjs CHANGED
@@ -14,7 +14,7 @@ import { fenced } from './fence.mjs';
14
14
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
15
15
  import { redactValue } from '@volter/teams/redact';
16
16
  import { randomBytes } from 'node:crypto';
17
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from 'node:fs';
18
18
  import { hostname } from 'node:os';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { DatabaseSync } from 'node:sqlite';
@@ -197,6 +197,31 @@ export const sessionBound = (db) => (sessionIndexed(db) ? SESSION_BOUND : SESSIO
197
197
  /** The statement that lists the open current runs whose launch is still pending. */
198
198
  export const pendingLaunches = (db) => (indexed(db, RUN_LAUNCH_PENDING_INDEX) ? PENDING_LAUNCHES : PENDING_LAUNCHES_BY_CARDS);
199
199
 
200
+ const EVENT_KIND_INDEX = 'idx_events_task_kind';
201
+ // creatorOf's first `created` event and the latest review events are point lookups in this order. The latest event
202
+ // of each kind can group from the index alone; IN-kind lookups still sort, but read only the selected kinds. Keep
203
+ // idx_events_task: statements asking for events since a time need its (task_id, created_at) order.
204
+ // Installed here on every backing's open, including an existing store, before any engine statement is prepared.
205
+ // Its first creation scans task_events once, sorts the keys and writes an entry per event (no payload in the index).
206
+ // SQLite can reuse free pages; this does not vacuum the store. It is synchronous and can hold the owner's loop for
207
+ // seconds, so say so BEFORE starting and report its time afterward. Write the owner's stderr synchronously even
208
+ // inside a caller's call: that call's err is buffered until its answer, after the migration has held the loop.
209
+ function layOutEventIndex(db) {
210
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'task_events'").get() || indexed(db, EVENT_KIND_INDEX)) return;
211
+ const report = (text) => { writeSync(2, text); if (current().kind === 'caller') current().err(text); };
212
+ report(`board: creating index ${EVENT_KIND_INDEX} on task_events: one-time table scan, key sort and index write; this synchronous migration holds the owner event loop until it finishes and can take seconds\n`);
213
+ const began = performance.now();
214
+ let made = false;
215
+ try {
216
+ db.exec(`CREATE INDEX IF NOT EXISTS ${EVENT_KIND_INDEX} ON task_events(task_id, kind, id)`);
217
+ made = true;
218
+ } finally {
219
+ const ms = performance.now() - began;
220
+ noteStep(`board open index ${EVENT_KIND_INDEX}`, ms);
221
+ report(`board: index ${EVENT_KIND_INDEX} ${made ? 'created' : 'creation failed'} after ${Math.round(ms)} ms\n`);
222
+ }
223
+ }
224
+
200
225
  /** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
201
226
  export function layOut(db, { fresh }) {
202
227
  if (fresh) db.exec(SCHEMA);
@@ -208,6 +233,7 @@ export function layOut(db, { fresh }) {
208
233
  // each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
209
234
  // any statement is prepared; no statement names it, so a store without it is only slower.
210
235
  db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
236
+ layOutEventIndex(db);
211
237
  // The calling session's run (CALLER_RUN_ADDRESS) and its cards (SESSION_CURRENT_RUNS, SESSION_BOUND, SESSION_RUNS):
212
238
  // without the session's index each lookup read every run's whole metadata, overflow pages included. `id` keeps a
213
239
  // session's runs in the order the lookups ask for, and the address makes the index answer the caller's lookup without
package/board/ztrack.mjs CHANGED
@@ -29,10 +29,43 @@ const timedStep = (step, fn) => { const started = performance.now(); try { retur
29
29
  // the reads of `board_state` and `board_errors` that come before each of the engine's statements, and a statement's
30
30
  // compilation, which reads the schema.
31
31
  const SLOW_STATEMENT_MS = 50;
32
+ // A statement that is a step also says what the whole process used while it ran (`used` on its step, named as a slow
33
+ // line's `whole_process` is): processor time, page faults, and context switches given up and taken away. The statement
34
+ // runs on the loop's thread and holds it, so over a statement these are nearly the statement's own. The store is read
35
+ // with pread (no memory map: `PRAGMA mmap_size` is 0), so a page SQLite does not hold and the system has to read is a
36
+ // switch given up, never a page fault and, on macOS, never an `fs_read`. Measured on macOS 26 (Darwin 25.4), a 741 MB
37
+ // store of 181,000 pages evicted from the system's cache before each of 20 runs: the creator's lookup of a card with
38
+ // 1,893 events read 1,116 pages in 80 to 88 ms with 7 to 11 ms of processor time, voluntary_switches 758 every time,
39
+ // major_page_faults 0 and fs_read 0; the same lookup with the file cached took 2.6 ms with voluntary_switches 0. So:
40
+ // voluntary_switches in the hundreds with little processor time is what waiting for file pages reads as (`ms` over
41
+ // that count is the wait for each read); few of them with little processor time is a wait that is not page reads.
42
+ // They are the whole process's, so a hold long enough for the loop's watcher to read the held stack (slow-log.mjs
43
+ // watchEventLoop) has that thread's use in them too. Measured over a 2 s sleep on a watched loop, two runs: 24 to 26
44
+ // ms of processor time, 149 to 234 minor page faults, 110 to 140 involuntary switches, no voluntary switch, and 85
45
+ // major page faults in one run against 0 in the next; unwatched, 0.6 ms, 17, 1, 0 and 0. So major page faults under a
46
+ // hundred in a long hold do not say the statement faulted, and the watcher adds no voluntary switch.
47
+ // The counters are read once before every statement (process.resourceUsage, 0.3 µs) and once more only for a step:
48
+ // a fast statement went from 491 to 917 ns (100,000 a round, five rounds, median).
49
+ const statementCounters = () => { try { return process.resourceUsage(); } catch { return null; } };
50
+ const statementUse = (before, after) => {
51
+ if (!before || !after) return null;
52
+ const ms = (micros) => Math.round(micros / 1000);
53
+ return { cpu_user_ms: ms(after.userCPUTime - before.userCPUTime), cpu_system_ms: ms(after.systemCPUTime - before.systemCPUTime),
54
+ major_page_faults: after.majorPageFault - before.majorPageFault, minor_page_faults: after.minorPageFault - before.minorPageFault, fs_read: after.fsRead - before.fsRead,
55
+ voluntary_switches: after.voluntaryContextSwitches - before.voluntaryContextSwitches, involuntary_switches: after.involuntaryContextSwitches - before.involuntaryContextSwitches };
56
+ };
57
+ // Called from `finally`: it answers nothing and never throws, so it can replace neither a statement's value nor its error.
58
+ const noteStatement = (sql, started, before) => {
59
+ try {
60
+ const ms = performance.now() - started;
61
+ if (ms >= SLOW_STATEMENT_MS) noteStep(`board statement: ${String(sql).replace(/\s+/g, ' ').trim().slice(0, 120)}`, ms, statementUse(before, statementCounters()));
62
+ } catch { /* the step is not noted */ }
63
+ };
32
64
  const timedStatement = (sql, fn) => {
65
+ const before = statementCounters();
33
66
  const started = performance.now();
34
67
  try { return fn(); }
35
- finally { const ms = performance.now() - started; if (ms >= SLOW_STATEMENT_MS) noteStep(`board statement: ${String(sql).replace(/\s+/g, ' ').trim().slice(0, 120)}`, ms); }
68
+ finally { noteStatement(sql, started, before); }
36
69
  };
37
70
  import { fire, workflowFor } from './workflow.mjs';
38
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.160",
3
+ "version": "0.5.162",
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,8 +24,8 @@
24
24
  "check-types": "node scripts/generate-types.mjs --check"
25
25
  },
26
26
  "dependencies": {
27
- "@volter/supercode-harness-sdk": "^0.3.97",
28
- "@volter/teams": "0.2.65",
27
+ "@volter/supercode-harness-sdk": "^0.3.98",
28
+ "@volter/teams": "0.2.66",
29
29
  "@volter/ztrack": "^2.4.23",
30
30
  "yaml": "^2.9.0"
31
31
  },