@volter/supercode-orchestrator 0.5.20 → 0.5.21

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.
@@ -15,7 +15,7 @@
15
15
  // Every side effect follows a committed transition: a card is claimed before its session is launched, and a
16
16
  // subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
17
17
  // launch and a crash between the two steps is answered by the next tick.
18
- import { monitorsRuns, failedObservation, surfaceIncident, auditEndedRuns } from './incidents.mjs';
18
+ import { monitorsRuns, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
19
19
  import { readEventFleet } from './event-fleet.mjs';
20
20
  let eventFleetRoot = null;
21
21
  import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
@@ -616,7 +616,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
616
616
  }
617
617
  } catch (error) {
618
618
  if (error.launchPending) { report.queued.push([task.id, error.message]); continue; }
619
- const payload = { error: String(error.message).slice(-500), ...(error.resumeFailed ? { resume_failed: true, ...error.resumeFailed } : {}) };
619
+ const payload = { error: String(error.message).slice(-500), origin: originOf(error), ...(error.resumeFailed ? { resume_failed: true, ...error.resumeFailed } : {}) };
620
620
  try { const r = tx(board, db => monitorsRuns(entry) ? (surfaceIncident(db,board,entry,task.id,'spawn_failed',payload), {effects:[]}) : fire(db, board, entry, task.id, 'spawn_failed', { roles: ['platform'], payload })); effects.push(...r.effects); } catch { /* no transition for it */ }
621
621
  report.failed.push([task.id, String(error.message).slice(-300)]);
622
622
  }
@@ -741,7 +741,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
741
741
  if (!lane) return null;
742
742
  try { return launchRun(board,entry,id,lane,fleet,settings); }
743
743
  catch (error) {
744
- if (monitorsRuns(entry) && !error.launchPending) tx(board,db=>surfaceIncident(db,board,entry,id,'spawn_failed',{error:error.message}));
744
+ if (monitorsRuns(entry) && !error.launchPending) tx(board,db=>surfaceIncident(db,board,entry,id,'spawn_failed',{error:error.message,origin:originOf(error)}));
745
745
  throw error;
746
746
  }
747
747
  }
@@ -1205,7 +1205,7 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
1205
1205
  if(session)report.started.push([id,session.address??session.session_id,session.mode]);
1206
1206
  }catch(error){
1207
1207
  if(error.launchPending){report.queued.push([id,error.message]);continue;}
1208
- const moved=tx(board,db=>monitorsRuns(entry)?(surfaceIncident(db,board,entry,id,'spawn_failed',{error:error.message}),{effects:[]}):fire(db,board,entry,id,'spawn_failed',{roles:['platform'],payload:{error:error.message}}));effects.push(...moved.effects);
1208
+ const moved=tx(board,db=>monitorsRuns(entry)?(surfaceIncident(db,board,entry,id,'spawn_failed',{error:error.message,origin:originOf(error)}),{effects:[]}):fire(db,board,entry,id,'spawn_failed',{roles:['platform'],payload:{error:error.message}}));effects.push(...moved.effects);
1209
1209
  report.failed.push([id,error.message]);
1210
1210
  }
1211
1211
  }
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 { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
- import { view, actorOf, principalOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
+ import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
16
16
  import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
17
17
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
18
18
 
@@ -148,8 +148,9 @@ function projectOf(root, idOrSlug) {
148
148
  if (!existsSync(file)) return null;
149
149
  const db = new DatabaseSync(file, { readOnly: true });
150
150
  try {
151
- return db.prepare('SELECT id, slug, name, primary_path FROM projects WHERE id = ?').get(idOrSlug)
152
- ?? db.prepare('SELECT id, slug, name, primary_path FROM projects WHERE slug = ?').get(String(idOrSlug).toLowerCase()) ?? null;
151
+ // another process writing the registry is waited out, never read as "no such project"
152
+ return retryContention(() => db.prepare('SELECT id, slug, name, primary_path FROM projects WHERE id = ?').get(idOrSlug)
153
+ ?? db.prepare('SELECT id, slug, name, primary_path FROM projects WHERE slug = ?').get(String(idOrSlug).toLowerCase()) ?? null);
153
154
  } catch { return null; } finally { db.close(); }
154
155
  }
155
156
 
@@ -511,15 +512,20 @@ function stopWorker(board, runId) {
511
512
  */
512
513
  export function recomputeReady(board, effects = [], errors = []) {
513
514
  const entry = workflowFor(board.root);
514
- return tx(board, (db) => {
515
+ // what the moves ask for is handed on only once the write commits: a retried write starts its lists again
516
+ const { moved, asked, failed } = tx(board, (db) => {
517
+ const asked = [], failed = [];
515
518
  let moved = 0;
516
519
  for (const { id } of all(db, "SELECT id FROM tasks WHERE status NOT IN ('done', 'archived')")) {
517
520
  const before = statusOf(db, getTask(db, id));
518
- try { settle(db, board, entry, id, effects); } catch (error) { errors.push([id, String(error.message).slice(-300)]); continue; }
521
+ // another writer's commit is the whole write's to retry, never one card's failure
522
+ try { settle(db, board, entry, id, asked); } catch (error) { if (contention(error)) throw error; failed.push([id, String(error.message).slice(-300)]); continue; }
519
523
  if (statusOf(db, getTask(db, id)) !== before) moved += 1;
520
524
  }
521
- return moved;
525
+ return { moved, asked, failed };
522
526
  });
527
+ effects.push(...asked); errors.push(...failed);
528
+ return moved;
523
529
  }
524
530
 
525
531
  // Hermes's invalidate_descendants_for_parent_reopen: every descendant that moved on because of the reopened card
@@ -47,7 +47,7 @@ export async function* followBoardPublications({root,after,signal}) {
47
47
  const paths=backingPaths(root,slug);
48
48
  file(paths.sqlite);file(`${paths.sqlite}-wal`);file(paths.ztrack);stores.push(paths.sqlite,`${paths.sqlite}-wal`);
49
49
  directory(paths.files,name=>name.endsWith('.json'));
50
- try {const cfg=JSON.parse(readFileSync(paths.ztrack,'utf8'));const document=resolve(dirname(paths.ztrack),cfg.document);file(document);file(`${document}.archive.md`);}catch{/* other backing */}
50
+ try {const cfg=JSON.parse(readFileSync(paths.ztrack,'utf8'));const document=resolve(dirname(paths.ztrack),cfg.document);file(document);const own=basename(document);directory(dirname(document),name=>name===`${own}.archive.md`||(name.startsWith(`${own}.archive-`)&&name.endsWith('.md')));}catch{/* other backing */}
51
51
  }
52
52
  };
53
53
  const aborted=()=>wake?.();signal?.addEventListener('abort',aborted);
@@ -3,6 +3,10 @@ import { event, json, now } from './store.mjs';
3
3
  import { statusOf, creatorOf } from './workflow.mjs';
4
4
 
5
5
  export const monitorsRuns = entry => entry?.params?.run_failure_policy !== 'retry';
6
+ // Where a thrown failure came from: its first frames, with paths from the package root, kept as the incident's
7
+ // evidence so a bare message ("database is locked") still names the statement's caller.
8
+ export const originOf = (error) => String(error?.stack ?? '').split('\n').filter((line) => /^\s+at /.test(line)).slice(0, 5)
9
+ .map((line) => line.trim().replace(/\((?:file:\/\/)?[^()]*\/(orchestrator\/|teams\/|volter-teams\/)/, '($1'));
6
10
  export const failedObservation = name => ['exited', 'session_closed', 'session_lost', 'spawn_failed', 'orphaned', 'timed_out', 'stale'].includes(name);
7
11
 
8
12
  export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId = null) {
package/board/store.mjs CHANGED
@@ -189,7 +189,7 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
189
189
  break;
190
190
  } catch (error) {
191
191
  try { db.exec('ROLLBACK'); } catch {}
192
- if (!/locked|busy/i.test(error.message)) throw error;
192
+ if (!contention(error)) throw error;
193
193
  }
194
194
  }
195
195
  return { db, root, slug, path, backing: use, tx: (fn) => sqliteTx(db, fn), close: () => db.close() };
@@ -204,18 +204,25 @@ export const tx = (board, fn) => {
204
204
  signalBoardWrite(board.root, actor);
205
205
  return result;
206
206
  };
207
- // A read that meets another process's write is retried as a write is (sqliteTx): a dispatcher service and a CLI share
208
- // the board, and a lost read must not become a launch failure. Inside a transaction its retry is the transaction's.
207
+ // SQLite's own contention (BUSY, LOCKED, and their extended codes) and nothing else: a refusal whose words happen to
208
+ // say "blocked" or "busy" reaches its caller at once instead of being retried forever.
209
+ export const contention = (error) => [5, 6].includes(Number(error?.errcode) & 255)
210
+ || ['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(error?.code)
211
+ || /^database (?:table |schema )?is locked$/i.test(error?.message ?? '');
209
212
  const pause = new Int32Array(new SharedArrayBuffer(4));
210
- export const view = (board, fn) => {
211
- for (;;) {
212
- try { return fn(board.db); }
213
+ /** Run `fn` until it answers without meeting another process's write (a short, randomized wait between tries). */
214
+ export function retryContention(fn) {
215
+ for (let attempt = 0; ; attempt++) {
216
+ try { return fn(); }
213
217
  catch (error) {
214
- if (inTx.has(board.db) || !/locked|busy/i.test(error.message)) throw error;
215
- Atomics.wait(pause, 0, 0, 10);
218
+ if (!contention(error)) throw error;
219
+ Atomics.wait(pause, 0, 0, Math.min(40, 2 ** Math.min(attempt, 5)) * (0.5 + Math.random()));
216
220
  }
217
221
  }
218
- };
222
+ }
223
+ // A read that meets another process's write is retried as a write is (sqliteTx): a dispatcher service and a CLI share
224
+ // the board, and a lost read must not become a launch failure. Inside a transaction its retry is the transaction's.
225
+ export const view = (board, fn) => inTx.has(board.db) ? fn(board.db) : retryContention(() => fn(board.db));
219
226
 
220
227
  // A deferred native transaction, with the whole operation retried on an optimistic conflict.
221
228
  // There is no separate application writer lock or contention timeout. A write inside one is part of it.
@@ -231,7 +238,8 @@ function sqliteTx(db, fn) {
231
238
  return out;
232
239
  } catch(error) {
233
240
  try {db.exec('ROLLBACK');} catch {}
234
- if (!/locked|busy/i.test(error.message))throw error;
241
+ if (!contention(error))throw error;
242
+ Atomics.wait(pause, 0, 0, 1 + Math.random() * 20);
235
243
  } finally { inTx.delete(db); }
236
244
  }
237
245
  }
package/board/wake.mjs CHANGED
@@ -74,7 +74,9 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
74
74
  const config = JSON.parse(readFileSync(join(dir, 'ztrack.json'), 'utf8'));
75
75
  const document = resolve(dir, config.document);
76
76
  file(document, `document:${document}`);
77
- file(`${document}.archive.md`, `document:${document}.archive.md`);
77
+ // the current archive and each rotated one (`<document>.archive-<date>.md`)
78
+ const own = basename(document);
79
+ directory(dirname(document), `document:${document}.archive`, name => name === `${own}.archive.md` || (name.startsWith(`${own}.archive-`) && name.endsWith('.md')));
78
80
  } catch { /* other backing or not yet created */ }
79
81
  }
80
82
  const config = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'supercode') : join(env.HOME ?? '', '.config', 'supercode'));
package/board/ztrack.mjs CHANGED
@@ -12,7 +12,7 @@ import { createHash, randomBytes } from 'node:crypto';
12
12
  import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
13
13
  import { basename, dirname, resolve } from 'node:path';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
- import { actorOf, event, json, now, principalOf, setActor } from './store.mjs';
15
+ import { actorOf, contention, event, json, now, principalOf, retryContention, setActor } from './store.mjs';
16
16
  import { GitDocument } from './git-document.mjs';
17
17
  import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
18
18
 
@@ -39,12 +39,13 @@ const atomic = (path, text) => { const tmp = `${path}.${process.pid}.tmp`; write
39
39
  const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
40
40
  const backoff = (attempt) => sleep(Math.min(40, 2 ** Math.min(attempt, 5)) * (0.5 + Math.random()));
41
41
 
42
- // Only SQLite's actual contention codes retry (BUSY, BUSY_SNAPSHOT, LOCKED). Rule refusals can
43
- // contain words such as "blocked" or "busy" and must reach the caller unchanged.
44
- const sqliteContention = error => [5, 6].includes(Number(error?.errcode) & 255)
45
- || ['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(error?.code)
46
- || /^database (?:table |schema )?is locked$/i.test(error?.message ?? '');
42
+ // Only SQLite's actual contention codes retry (store.mjs `contention`). Rule refusals can contain words such as
43
+ // "blocked" or "busy" and must reach the caller unchanged.
44
+ const sqliteContention = (error) => contention(error); // resolved at call time: store.mjs and this module import each other
47
45
  const RETRY = new Error('board changed while this operation read it; retry');
46
+ // The current archive's size before it rotates, unless the board declares its own (`archive.max_lines`).
47
+ const ARCHIVE_LINES = 2000;
48
+ const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
48
49
 
49
50
  const BOARD_TABLES = `
50
51
  CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -75,16 +76,20 @@ export function openZtrackBoard(marker, { create = false, document = null, seed
75
76
  const behindSaid = new Set();
76
77
 
77
78
  class ZtrackBoard {
78
- constructor({ document, seed, marker, init, root, slug, compatPath, commit = false, manager = null }) {
79
+ constructor({ document, seed, marker, init, root, slug, compatPath, commit = false, manager = null, archive = {} }) {
79
80
  this.path = resolve(dirname(marker), document);
80
81
  this.archive = `${this.path}.archive.md`;
81
- this.docs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
82
+ this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
83
+ // The current archive rotates (`"archive": {"max_lines": N, "period": "month"}` in the board's ztrack.json): past
84
+ // its size, or once its period has turned, its whole text moves into `<document>.archive-<date>.md`, which stays a
85
+ // document of the board. Retention: rotated archives are kept, checked in beside the board.
86
+ this.rotation = { lines: Number(archive.max_lines) > 0 ? Number(archive.max_lines) : ARCHIVE_LINES, period: archive.period === 'month' ? 'month' : null };
82
87
  // `"commit": true` in the board's ztrack.json: the documents live in a git checkout, and each write is committed as
83
88
  // its doer and pushed, after taking what was pushed (git-document.mjs; company RFC 0025 decision 3).
84
89
  // `"manager"`: the board's manager (its principal or session address), whose writes it commits itself as part of its
85
90
  // own work; the backing commits only the writes the manager does not make.
86
91
  this.manager = manager;
87
- this.git = commit ? new GitDocument([this.path, this.archive], (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
92
+ this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
88
93
  // A publish that is behind is said once per process, on whichever workflow verb opened the board: a refused push
89
94
  // surfaces on the next call instead of only in the dispatcher's report, and stops being said once the push lands.
90
95
  if (this.git && !behindSaid.has(this.path)) {
@@ -115,14 +120,22 @@ class ZtrackBoard {
115
120
  get: (...args) => self.read(() => self.statement(sql).get(...args)),
116
121
  all: (...args) => self.read(() => self.statement(sql).all(...args)),
117
122
  iterate: (...args) => self.read(() => self.statement(sql).all(...args))[Symbol.iterator](),
118
- run: (...args) => self.active ? self.statement(sql).run(...args) : self.tx(() => self.statement(sql).run(...args)),
123
+ run: (...args) => self.active ? self.noting(() => self.statement(sql).run(...args)) : self.tx(() => self.statement(sql).run(...args)),
119
124
  };
120
125
  },
121
- exec(sql) { return self.active ? self.conn.exec(sql) : self.tx(() => self.conn.exec(sql)); },
126
+ exec(sql) { return self.active ? self.noting(() => self.conn.exec(sql)) : self.tx(() => self.conn.exec(sql)); },
122
127
  close() { self.close(); },
123
128
  };
124
129
  if (seed) this.tx(() => {});
125
130
  }
131
+ // The board's documents: the open one, the current archive, and each rotated archive (recorded in the board's own
132
+ // state, so a rotation committed but not yet written is still a document of the board).
133
+ get docs() {
134
+ let rotated = [];
135
+ try { rotated = this.conn ? this.get('archives', []) : []; } catch { rotated = []; }
136
+ // recorded by file name, beside the document, so a board moved with its documents still finds them
137
+ return [...this.baseDocs, ...rotated.map(({ role, name }) => ({ role, path: resolve(dirname(this.path), name) }))];
138
+ }
126
139
  get codec() { return this._codec ??= require('@volter/ztrack/board-document'); }
127
140
  get preset() { return this._preset ??= require('@volter/ztrack/kanban-preset'); }
128
141
  board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
@@ -295,8 +308,14 @@ class ZtrackBoard {
295
308
 
296
309
  // ------------------------------------------------------------------ reads
297
310
 
311
+ // A statement inside a write that meets another writer marks the write: whatever the body does with the error (a
312
+ // per-card catch that reports it and moves on), the write retries whole instead of committing what is left.
313
+ noting(fn) {
314
+ try { return fn(); } catch (error) { if (sqliteContention(error)) this.contended = true; throw error; }
315
+ }
316
+
298
317
  read(fn) {
299
- if (this.active) return fn();
318
+ if (this.active) return this.noting(fn);
300
319
  this.observeForRead();
301
320
  for (let attempt = 0; ; attempt++) {
302
321
  try { return fn(); } catch (error) { if (!sqliteContention(error)) throw error; backoff(attempt); }
@@ -350,16 +369,18 @@ class ZtrackBoard {
350
369
  // A write inside a write is part of it: the pull below happened once, for the outermost one.
351
370
  if (this.active) return fn(this.db);
352
371
  // A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
372
+ if (this.git) this.git.paths = this.docs.map((doc) => doc.path);
353
373
  try { timedStep('board git pull', () => this.git?.pull()); } catch (error) { process.stderr.write(`board: ${error.message}\n`); }
354
374
  let txStarted = 0;
355
375
  for (let attempt = 0; ; attempt++) {
356
- const hermes = this.seed ? null : this.prepareHermes();
357
- this.dropOverlay();
358
- let outcome;
376
+ let outcome, hermes = null;
359
377
  try {
360
378
  txStarted = performance.now();
379
+ // Reading Hermes's export meets its writer (another process exporting) as contention, retried with the write.
380
+ hermes = this.seed ? null : this.prepareHermes();
381
+ this.dropOverlay();
361
382
  this.conn.exec('BEGIN');
362
- this.active = true;
383
+ this.active = true; this.contended = false;
363
384
  this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched');
364
385
  this.refused = new Set();
365
386
  this.loadErrors();
@@ -373,6 +394,7 @@ class ZtrackBoard {
373
394
  if (this.seed) this.seedRows(); else this.lift(hermes);
374
395
  this.snapshotHeld();
375
396
  const result = fn(this.db);
397
+ if (this.contended) throw RETRY;
376
398
  const files = this.render();
377
399
  this.statement('INSERT INTO main.board_export (name, key) SELECT DISTINCT name, key FROM temp.board_dirty WHERE true ON CONFLICT(name, key) DO UPDATE SET rev = rev + 1').run();
378
400
  const intent = files.length ? randomBytes(16).toString('hex') : null;
@@ -398,6 +420,7 @@ class ZtrackBoard {
398
420
  this.publish(outcome);
399
421
  // Committed as the session (or person) whose act this write was, and pushed.
400
422
  const byManager = this.manager !== null && (actorOf() === this.manager || principalOf() === this.manager);
423
+ if (this.git) this.git.paths = this.docs.map((doc) => doc.path);
401
424
  if (!byManager) try { timedStep('board git commit and push', () => this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}`)); }
402
425
  catch (error) { process.stderr.write(`board: ${error.message}\n`); }
403
426
  }
@@ -773,10 +796,11 @@ class ZtrackBoard {
773
796
  const held = [...parents].filter((id) => !this.refused.has(id) && heldBy(id));
774
797
  if (held.length) { this.applyPlan(this.observe([], held), { overlay: false }); parents = cardsTouched(); }
775
798
  if (this.seedIds) for (const id of this.seedIds) parents.add(id);
776
- const layout = { open: this.entries('open'), archived: this.entries('archived') };
799
+ const roles = this.docs.map((doc) => doc.role);
800
+ const layout = Object.fromEntries(roles.map((role) => [role, this.entries(role)]));
777
801
  const where = new Map();
778
- for (const role of ['open', 'archived']) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
779
- const edits = { open: [], archived: [] };
802
+ for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
803
+ const edits = Object.fromEntries(roles.map((role) => [role, []]));
780
804
  for (const id of parents) {
781
805
  if (heldBy(id)) continue;
782
806
  const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
@@ -784,7 +808,9 @@ class ZtrackBoard {
784
808
  if (task && state.subtask_of) continue;
785
809
  const at = where.get(id);
786
810
  if (!task) { if (at) edits[at.role].push({ index: at.index, remove: true }); continue; }
787
- const role = state.archived || ['done', 'cancelled'].includes(state.stage ?? task.status) ? 'archived' : 'open';
811
+ // An ended card stays in the archive that holds it (a rotated one included); one that newly ends goes to the current.
812
+ const ended = state.archived || ['done', 'cancelled'].includes(state.stage ?? task.status);
813
+ const role = !ended ? 'open' : isArchive(at?.role) ? at.role : 'archived';
788
814
  // Its ended subtasks: each its own entry in the archive document while the card is open (the open document lists
789
815
  // only what is still to do); back in the card's own text once the card has ended.
790
816
  for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(id)) {
@@ -793,12 +819,13 @@ class ZtrackBoard {
793
819
  if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); continue; }
794
820
  const subRecord = this.subtaskRecord(sub.id, sub, subState, id, where);
795
821
  const subRec = hash(JSON.stringify(subRecord));
796
- if (subAt?.role === 'archived' && subAt.entry.rec === subRec) continue;
822
+ const kept = isArchive(subAt?.role);
823
+ if (kept && subAt.entry.rec === subRec) continue;
797
824
  let subText;
798
- try { subText = this.codec.renderBoardCard(subRecord, subAt?.role === 'archived' ? subAt.entry.level : 2, this.docOf('archived').path); }
825
+ try { subText = this.codec.renderBoardCard(subRecord, kept ? subAt.entry.level : 2, this.docOf(kept ? subAt.role : 'archived').path); }
799
826
  catch (error) { this.errors.set(sub.id, error.message); continue; }
800
- const subEntry = { cid: null, id: subRecord.id, level: subAt?.role === 'archived' ? subAt.entry.level : 2, len: subText.length, rec: subRec, text: subText };
801
- if (subAt?.role === 'archived') edits.archived.push({ index: subAt.index, entry: subEntry });
827
+ const subEntry = { cid: null, id: subRecord.id, level: kept ? subAt.entry.level : 2, len: subText.length, rec: subRec, text: subText };
828
+ if (kept) edits[subAt.role].push({ index: subAt.index, entry: subEntry });
802
829
  else { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); edits.archived.push({ entry: subEntry }); }
803
830
  }
804
831
  const record = this.recordOf(id, task, state, where);
@@ -818,7 +845,7 @@ class ZtrackBoard {
818
845
  }
819
846
  const files = [];
820
847
  const published = this.get('published', {});
821
- for (const role of ['open', 'archived']) {
848
+ for (const role of roles) {
822
849
  if (!edits[role].length) continue;
823
850
  const doc = this.docOf(role);
824
851
  const current = this.fileHash(doc.path);
@@ -854,10 +881,38 @@ class ZtrackBoard {
854
881
  files.push({ role, path: doc.path, before: current.hash, after: hash(text), base: lf(current.raw), text });
855
882
  published[role] = hash(text);
856
883
  }
857
- if (files.length) this.put('published', published);
884
+ if (files.length) { this.rotate(files, published); this.put('published', published); }
858
885
  return files;
859
886
  }
860
887
 
888
+ // Past its declared size, or once its period has turned, the current archive's whole text and index move into a new
889
+ // dated archive document, and the current one starts empty. Part of the same write: retried, rolled back or
890
+ // recovered with it.
891
+ rotate(files, published) {
892
+ const current = files.find((file) => file.role === 'archived');
893
+ const text = current ? current.text : lf(this.fileHash(this.archive).raw);
894
+ const entries = this.entries('archived');
895
+ const started = this.get('archive_started', null);
896
+ const at = new Date();
897
+ if (started === null) this.put('archive_started', Math.floor(at.getTime() / 1000));
898
+ const turned = this.rotation.period === 'month' && started !== null && new Date(started * 1000).toISOString().slice(0, 7) !== at.toISOString().slice(0, 7);
899
+ if (!entries.length || !text || (text.split('\n').length <= this.rotation.lines && !turned)) return;
900
+ const archives = this.get('archives', []);
901
+ const day = at.toISOString().slice(0, 10);
902
+ let suffix = day;
903
+ for (let n = 2; archives.some((doc) => doc.role === `archived:${suffix}`) || existsSync(`${this.path}.archive-${suffix}.md`); n++) suffix = `${day}-${n}`;
904
+ const role = `archived:${suffix}`, path = `${this.path}.archive-${suffix}.md`;
905
+ this.put(`chunks:${role}`, entries);
906
+ this.put('chunks:archived', []);
907
+ this.put('archives', [...archives, { role, name: basename(path) }]);
908
+ this.put('archive_started', Math.floor(at.getTime() / 1000));
909
+ files.push({ role, path, before: hash(''), after: hash(text), base: '', text });
910
+ if (current) Object.assign(current, { after: hash(''), text: '' });
911
+ else { const was = this.fileHash(this.archive); files.push({ role: 'archived', path: this.archive, before: was.hash, after: hash(''), base: lf(was.raw), text: '' }); }
912
+ published[role] = hash(text);
913
+ published.archived = hash('');
914
+ }
915
+
861
916
  // The document record of card `id`, from its rows and its subtasks' rows.
862
917
  recordOf(id, row, c, where) {
863
918
  const fileId = (x) => where.get(x)?.entry.id ?? x;
@@ -1064,7 +1119,8 @@ class ZtrackBoard {
1064
1119
  let compat;
1065
1120
  try {
1066
1121
  compat = new DatabaseSync(this.compatPath, { readOnly: true });
1067
- return compat.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('tasks', 'task_events')").get().n === 2;
1122
+ // a moment of another process's export is not an absent export
1123
+ return retryContention(() => compat.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('tasks', 'task_events')").get().n) === 2;
1068
1124
  } catch { return false; } finally { compat?.close(); }
1069
1125
  }
1070
1126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.20",
3
+ "version": "0.5.21",
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": {