@volter/supercode-orchestrator 0.5.19 → 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.
- package/board/dispatch.mjs +4 -4
- package/board/engine.mjs +12 -6
- package/board/event-stream.mjs +1 -1
- package/board/incidents.mjs +4 -0
- package/board/store.mjs +18 -10
- package/board/wake.mjs +3 -1
- package/board/ztrack.mjs +83 -27
- package/package.json +2 -2
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/board/event-stream.mjs
CHANGED
|
@@ -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);
|
|
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);
|
package/board/incidents.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
208
|
-
//
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 (
|
|
215
|
-
Atomics.wait(pause, 0, 0,
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
43
|
-
//
|
|
44
|
-
const sqliteContention = error =>
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
779
|
-
const edits =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
801
|
-
if (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": {
|
|
@@ -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.
|
|
27
|
+
"@volter/supercode-harness-sdk": "^0.3.58",
|
|
28
28
|
"@volter/teams": "0.2.8",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|