@volter/supercode-orchestrator 0.5.210 → 0.5.212
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/README.md +15 -1
- package/agents.mjs +11 -2
- package/bin/orchestrator.mjs +5 -0
- package/board/agent-declarations.mjs +18 -1
- package/board/attachment-write.mjs +68 -0
- package/board/call.mjs +2 -1
- package/board/capacity.mjs +54 -41
- package/board/ci.mjs +58 -29
- package/board/cli.mjs +51 -6
- package/board/credential-guard.mjs +15 -2
- package/board/dispatch.mjs +122 -40
- package/board/engine.mjs +24 -13
- package/board/fleet-listeners.mjs +16 -5
- package/board/published.mjs +18 -17
- package/board/queries.mjs +30 -0
- package/board/store.mjs +18 -2
- package/board/workflow.mjs +42 -16
- package/board/workspace.mjs +21 -10
- package/board-input.d.ts +2 -0
- package/board-input.mjs +33 -1
- package/hermes-cli.mjs +7 -19
- package/index.d.ts +1 -1
- package/loop.mjs +11 -1
- package/mcp/mount.mjs +22 -14
- package/orchestration.mjs +29 -2
- package/package.json +4 -5
package/board/published.mjs
CHANGED
|
@@ -38,7 +38,7 @@ CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_pub
|
|
|
38
38
|
// Columns the publication tables gained after their first layout; store.mjs layOut adds each one a store lacks. A head's
|
|
39
39
|
// fingerprint is the cheap reading of its card's rows the head was last compared at (fingerprintOf); the log's prune_from
|
|
40
40
|
// is where a prune under way resumes, null when none is (prunePublications).
|
|
41
|
-
export const PUBLICATION_COLUMNS = { supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
|
|
41
|
+
export const PUBLICATION_COLUMNS = { supercode_publications: { snapshot_bytes: 'INTEGER' }, supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
|
|
42
42
|
|
|
43
43
|
// The snapshot's shape: 1 names the card as a person does (`ref`, VO-856).
|
|
44
44
|
const SNAPSHOT_SHAPE = 4;
|
|
@@ -56,7 +56,7 @@ const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comm
|
|
|
56
56
|
export function publicationTriggers(db) {
|
|
57
57
|
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
|
|
58
58
|
const first = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_publish_tasks_insert'").get();
|
|
59
|
-
const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST].map((kind) => `'${kind}'`).join(',')})`;
|
|
59
|
+
const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
|
|
60
60
|
const agentMigration = tables.has('task_events') && !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'agent_assignment_adopted'");
|
|
61
61
|
const scopeMigration = !db.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_supercode_card_agent_scopes_insert'").get();
|
|
62
62
|
// A snapshot names its card's number: a store that numbers its cards republishes every card once, when that is watched.
|
|
@@ -64,11 +64,12 @@ export function publicationTriggers(db) {
|
|
|
64
64
|
// A snapshot of a newer shape (SNAPSHOT_SHAPE) than the heads were published in: every card is published once again.
|
|
65
65
|
const shapeMigration = Number(db.prepare('SELECT max(version) AS version FROM supercode_publication_shape').get()?.version ?? 0) < SNAPSHOT_SHAPE;
|
|
66
66
|
const contentMigration = !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_comments_update'").get()?.sql.includes('fingerprint=NULL');
|
|
67
|
+
const archiveMigration = !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'archived'");
|
|
67
68
|
db.exec('SAVEPOINT supercode_publication_marking');
|
|
68
69
|
try {
|
|
69
70
|
// Existing event triggers only watched reviews. Replace that filter once, preserving the savepoint rollback.
|
|
70
71
|
if(contentMigration)for(const table of ['task_comments','task_events'])for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_${table}_${verb}`);
|
|
71
|
-
if(agentMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
|
|
72
|
+
if(agentMigration||archiveMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
|
|
72
73
|
for (const [table, columns] of MARKED) {
|
|
73
74
|
if (!tables.has(table)) continue;
|
|
74
75
|
// Reviews and agent assignment events change a snapshot: an update marks when the
|
|
@@ -80,7 +81,7 @@ export function publicationTriggers(db) {
|
|
|
80
81
|
CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_update AFTER UPDATE ON ${table}${when(['OLD', 'NEW'])} BEGIN ${mark('NEW')} ${mark('OLD')} END;
|
|
81
82
|
CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_delete AFTER DELETE ON ${table}${when(['OLD'])} BEGIN ${mark('OLD')} END;`);
|
|
82
83
|
}
|
|
83
|
-
if ((first || contentMigration || agentMigration || scopeMigration || numberMigration || shapeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
|
|
84
|
+
if ((first || contentMigration || archiveMigration || agentMigration || scopeMigration || numberMigration || shapeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
|
|
84
85
|
SELECT id FROM tasks UNION SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed'
|
|
85
86
|
EXCEPT SELECT task_id FROM supercode_publication_dirty`);
|
|
86
87
|
if (shapeMigration) { db.exec('DELETE FROM supercode_publication_shape'); db.prepare('INSERT INTO supercode_publication_shape (version) VALUES (?)').run(SNAPSHOT_SHAPE); }
|
|
@@ -100,8 +101,8 @@ export function markRemovedHeads(db) {
|
|
|
100
101
|
const RETAINED_CHANGES = 1024;
|
|
101
102
|
// The changes one prune transaction deletes: a change's snapshot averaged about 190 KB and reached 3.7 MB, and deleting
|
|
102
103
|
// every pruned change in one statement held the owner's event loop for 291 s (2026-10-21).
|
|
103
|
-
const PRUNE_BATCH =
|
|
104
|
-
const PRUNE_BYTES =
|
|
104
|
+
const PRUNE_BATCH = 16;
|
|
105
|
+
const PRUNE_BYTES = 1024 * 1024;
|
|
105
106
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
106
107
|
const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
|
|
107
108
|
const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
|
|
@@ -206,13 +207,10 @@ const perCard=db=>{
|
|
|
206
207
|
};
|
|
207
208
|
// The columns of a task row that change while its card runs and that no snapshot reads (cardSnapshot).
|
|
208
209
|
const UNREAD_TASK_COLUMNS = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at']);
|
|
209
|
-
// The events a snapshot reads: its reviews, its agents and its archive reason.
|
|
210
|
-
const SNAPSHOT_EVENT_KINDS = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
|
|
211
210
|
const fingerprintReads=db=>({
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
events:db.prepare(`SELECT count(*) AS n,max(id) AS last,total(octet_length(payload)) AS bytes FROM task_events WHERE task_id=? AND kind IN ${SNAPSHOT_EVENT_KINDS}`),
|
|
211
|
+
// Comment/event inserts, updates and deletes invalidate the head's fingerprint in their native transaction.
|
|
212
|
+
// Even a same-length in-place edit forces a snapshot comparison; rereading every history row's header here adds
|
|
213
|
+
// no information and held the owner's loop for 1.9 s on one card's comment history.
|
|
216
214
|
facts:db.prepare('SELECT facts_json,receipt_sequence,declared_event_id FROM supercode_card_agent_facts WHERE task_id=?'),
|
|
217
215
|
scope:db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?'),
|
|
218
216
|
// a run's metadata changes in place (its session's observations), so every run's snapshot columns are read whole
|
|
@@ -227,7 +225,7 @@ const fingerprintReads=db=>({
|
|
|
227
225
|
function fingerprintOf(reads,rows,row,key,options){
|
|
228
226
|
const task={};for(const [column,value] of Object.entries(row))if(!UNREAD_TASK_COLUMNS.has(column))task[column]=value;
|
|
229
227
|
return hash(JSON.stringify([SNAPSHOT_SHAPE,SNAPSHOT_RELEASE,key,Boolean(options.nativeHolds),task,rows.own(row.id)??null,rows.number(row.id),rows.parents(row.id),rows.children(row.id),
|
|
230
|
-
reads.
|
|
228
|
+
reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
|
|
231
229
|
}
|
|
232
230
|
/** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
|
|
233
231
|
* (`VO-856`, store.mjs cardRef), null for a card with no number. */
|
|
@@ -270,7 +268,7 @@ export function recordCardPublications(db,actor,root,key) {
|
|
|
270
268
|
db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
|
|
271
269
|
const rows=perCard(db),reads=fingerprintReads(db),task=db.prepare('SELECT * FROM tasks WHERE id=?'),head=db.prepare('SELECT resource_id,digest,last_event_id,fingerprint FROM supercode_publication_heads WHERE resource_id=?');
|
|
272
270
|
const lastEvent=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1');
|
|
273
|
-
const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (
|
|
271
|
+
const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at,snapshot_bytes) VALUES (?,?,?,?,?,?,?)');
|
|
274
272
|
const upsert=db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id,fingerprint) VALUES (?,?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id,fingerprint=excluded.fingerprint');
|
|
275
273
|
const compared=db.prepare('UPDATE supercode_publication_heads SET fingerprint=? WHERE resource_id=?');
|
|
276
274
|
let changed=0;
|
|
@@ -285,10 +283,11 @@ export function recordCardPublications(db,actor,root,key) {
|
|
|
285
283
|
if(previous?.digest===digest){compared.run(`${fingerprint}:${digest}`,id);continue;}
|
|
286
284
|
const last=lastEvent.get(id);
|
|
287
285
|
const kind=`task.${previous&&previous.digest!=='removed'?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
|
|
288
|
-
const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now());
|
|
286
|
+
const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
|
|
289
287
|
upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null,`${fingerprint}:${digest}`);changed++;
|
|
290
288
|
}else if(previous&&previous.digest!=='removed'){
|
|
291
|
-
const
|
|
289
|
+
const snapshot=JSON.stringify({id,removed:true});
|
|
290
|
+
const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
|
|
292
291
|
db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=?,fingerprint=NULL WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),id);changed++;
|
|
293
292
|
}
|
|
294
293
|
}
|
|
@@ -330,7 +329,9 @@ export function prunePublications(db) {
|
|
|
330
329
|
from=0;
|
|
331
330
|
}
|
|
332
331
|
const floor=Number(db.prepare('SELECT pruned_through FROM supercode_publication_log').get().pruned_through);
|
|
333
|
-
|
|
332
|
+
// New rows carry their byte count. Legacy rows fall back one small page at a time; never backfill old snapshots
|
|
333
|
+
// during an ordinary verb or scan hundreds of snapshot headers before the byte budget can be applied.
|
|
334
|
+
const candidates=db.prepare(`SELECT sequence,COALESCE(snapshot_bytes,octet_length(snapshot)) AS bytes FROM supercode_publications p WHERE sequence>? AND sequence<=? AND ${OLDER} ORDER BY sequence LIMIT ?`).all(from,floor,PRUNE_BATCH);
|
|
334
335
|
const batch=[];let bytes=0;
|
|
335
336
|
for(const row of candidates){
|
|
336
337
|
// A single oversized row still makes progress, in a transaction of its own.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Local reads whose cost must follow the matching keys, not the board's accumulated history.
|
|
2
|
+
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
3
|
+
|
|
4
|
+
/** One reverse index seek per kind, then one payload read. ORDER BY id over an IN-kind filter can instead choose
|
|
5
|
+
* the task/id index and read unrelated event payloads to reject their kinds. Kinds and columns are code constants. */
|
|
6
|
+
export function latestEvent(db, task, kinds, columns = 'payload') {
|
|
7
|
+
const seeks = kinds.map(kind => `SELECT (SELECT id FROM task_events WHERE task_id=? AND kind='${kind}' ORDER BY id DESC LIMIT 1) AS id`);
|
|
8
|
+
return db.prepare(`SELECT ${columns} FROM task_events WHERE id=(SELECT MAX(id) FROM (${seeks.join(' UNION ALL ')}))`).get(...kinds.map(() => task));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read run history in finite, indexed pages, yielding before each page. The candidate page precedes any JSON
|
|
12
|
+
* predicate, so a page that matches nothing still reads at most 32 rows. Mutations must re-read their target. */
|
|
13
|
+
export async function readRunPages(db, columns, { task = null, where = '1' } = {}) {
|
|
14
|
+
const high = Number(db.prepare('SELECT MAX(id) AS id FROM task_runs').get()?.id ?? 0);
|
|
15
|
+
const out = [];
|
|
16
|
+
let cursor = 0;
|
|
17
|
+
while (cursor < high) {
|
|
18
|
+
await yieldToOwner();
|
|
19
|
+
const ids = db.prepare(`SELECT id FROM task_runs WHERE id>? AND id<=?${task ? ' AND task_id=?' : ''} ORDER BY id LIMIT 32`)
|
|
20
|
+
.all(cursor, high, ...(task ? [task] : []));
|
|
21
|
+
if (!ids.length) break;
|
|
22
|
+
cursor = ids.at(-1).id;
|
|
23
|
+
out.push(...db.prepare(`SELECT ${columns} FROM task_runs WHERE id IN (SELECT value FROM json_each(?)) AND (${where}) ORDER BY id`)
|
|
24
|
+
.all(JSON.stringify(ids.map(row => row.id))));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Unended runs plus ended runs a card still names current. Both arms start from small indexed sets. */
|
|
30
|
+
export const ACTIVE_RUN_IDS = 'SELECT id FROM task_runs WHERE ended_at IS NULL UNION SELECT current_run_id FROM tasks WHERE current_run_id IS NOT NULL';
|
package/board/store.mjs
CHANGED
|
@@ -221,6 +221,11 @@ export function listBoards(root) {
|
|
|
221
221
|
// SQLite uses an expression index only for a statement that writes the expression the same way.
|
|
222
222
|
const RUN_SESSION = "json_extract(metadata, '$.supercode.session_id')";
|
|
223
223
|
const RUN_ADDRESS = "json_extract(metadata, '$.supercode.address')";
|
|
224
|
+
// Notification records historically skipped malformed metadata through the JS parser. Keep that behavior in both
|
|
225
|
+
// the lookup and its indexes, including a legacy store that cannot create the older unguarded JSON indexes.
|
|
226
|
+
const NOTICE_SESSION = `CASE WHEN json_valid(metadata,6) THEN ${RUN_SESSION} END`;
|
|
227
|
+
const NOTICE_ADDRESS = `CASE WHEN json_valid(metadata,6) THEN ${RUN_ADDRESS} END`;
|
|
228
|
+
export const NOTICE_RUN_IDS = `SELECT id FROM task_runs WHERE ${NOTICE_SESSION}=? UNION SELECT id FROM task_runs WHERE ${NOTICE_ADDRESS}=?`;
|
|
224
229
|
const RUN_LAUNCH_PENDING = "json_extract(metadata, '$.supercode.launch_pending')";
|
|
225
230
|
export const CALLER_RUN_ADDRESS = `SELECT ${RUN_ADDRESS} AS address FROM task_runs WHERE ${RUN_SESSION} = ? ORDER BY id DESC LIMIT 1`;
|
|
226
231
|
// Launch/adoption commits the native address and its immutable role-agent together.
|
|
@@ -242,6 +247,10 @@ const RUN_LAUNCH_PENDING_INDEX = 'idx_runs_supercode_launch_pending';
|
|
|
242
247
|
const RUN_INDEXES = [
|
|
243
248
|
[RUN_SESSION_INDEX, `(${RUN_SESSION}, id, ${RUN_ADDRESS})`],
|
|
244
249
|
[RUN_LAUNCH_PENDING_INDEX, `(id) WHERE ${RUN_LAUNCH_PENDING} = 1`],
|
|
250
|
+
['idx_runs_notice_session', `(${NOTICE_SESSION}, id)`],
|
|
251
|
+
['idx_runs_notice_address', `(${NOTICE_ADDRESS}, id)`],
|
|
252
|
+
['idx_runs_open_machine_session', `(json_extract(metadata, '$.supercode.machine'), ${RUN_SESSION}, id) WHERE ended_at IS NULL`],
|
|
253
|
+
['idx_runs_open_machine_pane', `(json_extract(metadata, '$.supercode.machine'), json_extract(metadata, '$.supercode.pane'), id) WHERE ended_at IS NULL`],
|
|
245
254
|
];
|
|
246
255
|
|
|
247
256
|
// What every verb a session runs asks about its caller (cli.mjs ownCards, dispatch.mjs reconcileCallerLaunch). Each
|
|
@@ -395,6 +404,13 @@ export function layOut(db, { fresh }) {
|
|
|
395
404
|
// each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
|
|
396
405
|
// any statement is prepared; no statement names it, so a store without it is only slower.
|
|
397
406
|
db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
|
|
407
|
+
// Current-run joins and unended-run reconciliation must never search all cards/runs for each historical run.
|
|
408
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_current_run ON tasks(current_run_id) WHERE current_run_id IS NOT NULL');
|
|
409
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_runs_unended ON task_runs(id) WHERE ended_at IS NULL');
|
|
410
|
+
// Ordinary comments vastly outnumber explicit source declarations. Keep their JSON discriminator in a small index.
|
|
411
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_events_arc_sources ON task_events(task_id,id) WHERE kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array'");
|
|
412
|
+
// Capacity pages need these columns without visiting task body/result overflow pages.
|
|
413
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_capacity ON tasks(id,status,assignee,current_run_id,model_override)');
|
|
398
414
|
layOutNumbers(db);
|
|
399
415
|
layOutEventIndex(db);
|
|
400
416
|
layOutHistoryIndexes(db);
|
|
@@ -500,7 +516,7 @@ export function heldConfigWatched(slug, source, watched) {
|
|
|
500
516
|
|
|
501
517
|
export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
|
|
502
518
|
// a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
|
|
503
|
-
if (current().readOnly) return
|
|
519
|
+
if (current().readOnly) return openBoardSnapshot(root, slug);
|
|
504
520
|
if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
|
|
505
521
|
let entry = heldBoards.get(slug);
|
|
506
522
|
if (!entry) {
|
|
@@ -537,7 +553,7 @@ function heldHandle(entry, slug) {
|
|
|
537
553
|
* reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
|
|
538
554
|
* (listing.mjs byOwner).
|
|
539
555
|
*/
|
|
540
|
-
function
|
|
556
|
+
export function openBoardSnapshot(root, slug) {
|
|
541
557
|
const use = backingOf(root, slug);
|
|
542
558
|
if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
543
559
|
const path = backingPaths(root, slug)[use];
|
package/board/workflow.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { evaluate, holds, render, needsSources } from './expr.mjs';
|
|
|
20
20
|
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
21
21
|
import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
|
|
22
22
|
import { cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
23
|
+
import { latestEvent } from './queries.mjs';
|
|
23
24
|
|
|
24
25
|
const loaded = new Map();
|
|
25
26
|
// the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
|
|
@@ -189,7 +190,7 @@ export function completionOwnerOf(db, id) {
|
|
|
189
190
|
|
|
190
191
|
/** A card's explicitly named review profile, retained in its own history. */
|
|
191
192
|
export function reviewerOf(db, id) {
|
|
192
|
-
return json(
|
|
193
|
+
return json(latestEvent(db, id, ['created', 'reviewer_set'])?.payload, {})?.reviewer ?? null;
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
export function latestReview(db, id) {
|
|
@@ -381,6 +382,14 @@ function stateRequestOf(db, id) {
|
|
|
381
382
|
|
|
382
383
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
383
384
|
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
385
|
+
/** Card bodies use Markdown headings and standalone labels such as `Acceptance:` and `Tasks:`. Every label ends
|
|
386
|
+
* the preceding section, so a task or another section's checkbox never acquires Acceptance permissions. */
|
|
387
|
+
export function cardSectionHeading(line) {
|
|
388
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
389
|
+
if (heading) return heading[1].replace(/:$/, '').toLowerCase();
|
|
390
|
+
const label = /^\s*([A-Za-z][A-Za-z0-9 _-]*):\s*$/.exec(line);
|
|
391
|
+
return label ? label[1].trim().toLowerCase() : null;
|
|
392
|
+
}
|
|
384
393
|
/** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
|
|
385
394
|
* and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
|
|
386
395
|
* needs, added after it); no other line added, removed or changed. */
|
|
@@ -392,8 +401,8 @@ function onlyTicks(was, body) {
|
|
|
392
401
|
// an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
|
|
393
402
|
let section = null;
|
|
394
403
|
return before.every((line, i) => {
|
|
395
|
-
const heading =
|
|
396
|
-
if (heading) section = heading
|
|
404
|
+
const heading = cardSectionHeading(line);
|
|
405
|
+
if (heading !== null) section = heading;
|
|
397
406
|
if (line === after[i]) return true;
|
|
398
407
|
if (section !== 'acceptance') return false;
|
|
399
408
|
const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
|
|
@@ -469,7 +478,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
469
478
|
get latest_review() { return latestReview(db, task.id) ?? {}; },
|
|
470
479
|
get review_candidate() { return reviewCandidate(board, task.id) ?? {}; },
|
|
471
480
|
ci: completionEvidence(board, task.id),
|
|
472
|
-
get dispatch_hold() { return
|
|
481
|
+
get dispatch_hold() { return latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'kind')?.kind === 'dispatch_held'; },
|
|
473
482
|
status,
|
|
474
483
|
blocked_by: blocks,
|
|
475
484
|
paused: json(state.paused, null),
|
|
@@ -486,8 +495,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
486
495
|
const lines = String(task.body ?? '').split('\n');
|
|
487
496
|
let acceptance = false;
|
|
488
497
|
return lines.every((line) => {
|
|
489
|
-
const heading =
|
|
490
|
-
if (heading) acceptance = heading
|
|
498
|
+
const heading = cardSectionHeading(line);
|
|
499
|
+
if (heading !== null) acceptance = heading === 'acceptance';
|
|
491
500
|
if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
|
|
492
501
|
return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
|
|
493
502
|
});
|
|
@@ -1054,7 +1063,7 @@ const ACTIONS = {
|
|
|
1054
1063
|
else runSession(ctx, (s) => ({ ...s, [counter]: 0 }));
|
|
1055
1064
|
}
|
|
1056
1065
|
},
|
|
1057
|
-
end_run(ctx, { outcome, summary = null, error = null } = {}) {
|
|
1066
|
+
end_run(ctx, { outcome, summary = null, error = null, close_session = true } = {}) {
|
|
1058
1067
|
const row = one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id);
|
|
1059
1068
|
const t = now();
|
|
1060
1069
|
if (row?.current_run_id) {
|
|
@@ -1062,10 +1071,15 @@ const ACTIONS = {
|
|
|
1062
1071
|
retireSupersededRuns(ctx.db, ctx.id, runId, json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode);
|
|
1063
1072
|
run(ctx.db, `UPDATE task_runs SET status = ?, outcome = ?, summary = COALESCE(?, summary), error = COALESCE(?, error), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
|
|
1064
1073
|
WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
|
|
1065
|
-
requestSessionClose(ctx.db, ctx.id, { runId });
|
|
1066
1074
|
ctx.runId = runId;
|
|
1067
1075
|
ctx.lastOutcome = outcome;
|
|
1068
|
-
ctx
|
|
1076
|
+
runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
|
|
1077
|
+
// A declared completion may end the attempt while retaining its role session. In particular, its own caller
|
|
1078
|
+
// must be able to finish and return before the board starts the independent review.
|
|
1079
|
+
if (close_session) {
|
|
1080
|
+
requestSessionClose(ctx.db, ctx.id, { runId });
|
|
1081
|
+
ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
|
|
1082
|
+
}
|
|
1069
1083
|
} else if (summary || ctx.scope.event?.metadata) {
|
|
1070
1084
|
// an outcome reached with no run open (a person completing a card nobody claimed)
|
|
1071
1085
|
const task = one(ctx.db, 'SELECT assignee FROM tasks WHERE id = ?', ctx.id);
|
|
@@ -1140,13 +1154,24 @@ const ACTIONS = {
|
|
|
1140
1154
|
const task = cardOf(ctx.db, ctx.id);
|
|
1141
1155
|
const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
|
|
1142
1156
|
run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
|
|
1157
|
+
if (readable !== task.readable_id) {
|
|
1158
|
+
// Keep the obligation with the edit: a stopped dispatcher or interrupted caller must not lose the rename.
|
|
1159
|
+
// Delivery reads the current name, so an older edit can never restore an obsolete name.
|
|
1160
|
+
const effect = { kind: 'rename_card_agents', id: ctx.id };
|
|
1161
|
+
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
1162
|
+
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
1163
|
+
}
|
|
1143
1164
|
}
|
|
1144
1165
|
if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
|
|
1145
1166
|
if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
|
|
1146
1167
|
},
|
|
1147
1168
|
decompose(ctx, said) {
|
|
1148
|
-
//
|
|
1149
|
-
|
|
1169
|
+
// Native arc tasks are worked by the arc's session, never dispatched as prerequisite cards of their own.
|
|
1170
|
+
// Retain the older workflow's dependency action only for separately declared cards.
|
|
1171
|
+
for (const child of said?.children ?? []) {
|
|
1172
|
+
if (one(ctx.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id=?', child)?.subtask_of === ctx.id) continue;
|
|
1173
|
+
run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
|
|
1174
|
+
}
|
|
1150
1175
|
},
|
|
1151
1176
|
// card ids a handoff names that no card has (Hermes flags them the same way)
|
|
1152
1177
|
check_references(ctx, said) {
|
|
@@ -1231,11 +1256,12 @@ const ACTIONS = {
|
|
|
1231
1256
|
/** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
|
|
1232
1257
|
export function retireSupersededRuns(db, id, runId, session) {
|
|
1233
1258
|
if (!session?.session_id && !session?.pane) return;
|
|
1234
|
-
for (const old of all(db, `SELECT id,
|
|
1235
|
-
AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1259
|
+
for (const old of all(db, `SELECT id,task_id,metadata FROM task_runs WHERE id IN (
|
|
1260
|
+
SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1261
|
+
AND json_extract(metadata, '$.supercode.session_id')=?
|
|
1262
|
+
UNION SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1263
|
+
AND json_extract(metadata, '$.supercode.pane')=?)`,
|
|
1264
|
+
runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
|
|
1239
1265
|
const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
|
|
1240
1266
|
if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
|
|
1241
1267
|
if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
|
package/board/workspace.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Filesystem/RPC work happens before a board transaction; the ownership check is pure.
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
5
5
|
import { inspectCheckout } from '@volter/teams/checkout';
|
|
@@ -130,23 +130,34 @@ export async function checkWorkspace(board, task) {
|
|
|
130
130
|
if (task.workspace_kind === 'scratch') return null;
|
|
131
131
|
task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
|
|
132
132
|
const facts = await resolveWorkspace(task), peers = [];
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
|
|
134
|
+
// New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
|
|
135
|
+
// once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
|
|
136
|
+
{
|
|
137
|
+
for (const row of board.db.prepare(`SELECT t.id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
|
|
135
138
|
if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
|
|
136
139
|
let other;
|
|
137
|
-
try { other = await resolveWorkspace({ ...row,
|
|
138
|
-
if (
|
|
140
|
+
try { other = await resolveWorkspace({ ...row, handle: cardHandleOf(board, row.id) }); } catch { continue; } // Its retained path/reservation is still checked below.
|
|
141
|
+
if ((row.workspace_kind === 'worktree' ? other.path : other.root) === workspaceRoot) peers.push(row.id);
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
|
-
const checked = { ...facts,
|
|
144
|
+
const checked = { ...facts, workspaceRoot, peers };
|
|
142
145
|
assertWorkspaceOwner(board.db, task, checked);
|
|
143
146
|
return checked;
|
|
144
147
|
}
|
|
148
|
+
const workspacePath = (path, local) => {
|
|
149
|
+
if (!path) return null;
|
|
150
|
+
if (local) { try { return realpathSync(path); } catch { /* a pre-launch reservation may not exist yet */ } }
|
|
151
|
+
return path.replace(/\/+$/, '');
|
|
152
|
+
};
|
|
145
153
|
export function assertWorkspaceOwner(db, task, checked) {
|
|
146
|
-
if (!checked?.
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
if (!checked?.workspaceRoot) return;
|
|
155
|
+
const machine = normalMachine(task.machine ?? localMachine()), local = machine === localMachine();
|
|
156
|
+
const path = workspacePath(checked.workspaceRoot, local);
|
|
157
|
+
const conflict = db.prepare(`SELECT t.id,t.workspace_path,c.machine,c.workspace_root FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch'`).all()
|
|
158
|
+
.find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === machine &&
|
|
159
|
+
(workspacePath(row.workspace_root, local) === path || workspacePath(row.workspace_path, local) === path || checked.peers?.includes(row.id)));
|
|
160
|
+
if (conflict) throw new Error(`workspace ${checked.path} is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();
|
package/board-input.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** Read a remote card write's selected text file (or stdin) on the caller's machine. */
|
|
2
2
|
export function prepareBoardText(argv: string[]): { argv: string[]; stdin: string | null };
|
|
3
|
+
/** Read a caller-local attachment in bounded requests, ending with its digest. */
|
|
4
|
+
export function prepareBoardInputs(argv: string[]): Generator<{ argv: string[]; stdin: string | null }>;
|
package/board-input.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// A remote card write reads its text on the caller's machine, before the board door is called.
|
|
2
2
|
// The board's own argument parser decides which file is used; its last value wins.
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { basename } from 'node:path';
|
|
4
6
|
import { parse } from './board/args.mjs';
|
|
5
7
|
|
|
6
8
|
/** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
|
|
@@ -14,3 +16,33 @@ export function prepareBoardText(argv) {
|
|
|
14
16
|
// reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
|
|
15
17
|
return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
|
|
16
18
|
}
|
|
19
|
+
|
|
20
|
+
/** Each yielded request fits one board frame. Only the last answer is the attachment receipt. */
|
|
21
|
+
export function* prepareBoardInputs(argv) {
|
|
22
|
+
const args = parse(argv);
|
|
23
|
+
if (args._[0] !== 'attach' || args.help) { yield prepareBoardText(argv); return; }
|
|
24
|
+
if (args._.length !== 3 || args.upload !== undefined) throw new Error('attach needs a card id and a local file');
|
|
25
|
+
const source = openSync(args._[2], 'r');
|
|
26
|
+
try {
|
|
27
|
+
const before = fstatSync(source, { bigint: true });
|
|
28
|
+
if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('attach needs a regular file of supported size');
|
|
29
|
+
const upload = randomUUID(), size = Number(before.size), hash = createHash('sha256');
|
|
30
|
+
const command = ['attach', args._[1], '-', '--upload', upload];
|
|
31
|
+
if (args.json) command.push('--json');
|
|
32
|
+
for (const key of ['name', 'content-type', 'author']) if (args[key] !== undefined) command.push(`--${key}`, args[key]);
|
|
33
|
+
const input = body => ({ argv: command, stdin: JSON.stringify(body) });
|
|
34
|
+
yield input({ action: 'begin', size, name: args.name ?? basename(args._[2]) });
|
|
35
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
36
|
+
for (let offset = 0; offset < size;) {
|
|
37
|
+
const count = readSync(source, buffer, 0, Math.min(buffer.length, size - offset), offset);
|
|
38
|
+
if (count <= 0) throw new Error('attachment ended during upload; no attachment was committed');
|
|
39
|
+
const bytes = buffer.subarray(0, count); hash.update(bytes);
|
|
40
|
+
yield input({ action: 'append', offset, bytes: bytes.toString('base64') });
|
|
41
|
+
offset += count;
|
|
42
|
+
}
|
|
43
|
+
const after = fstatSync(source, { bigint: true });
|
|
44
|
+
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
|
|
45
|
+
throw new Error('attachment changed during upload; no attachment was committed');
|
|
46
|
+
yield input({ action: 'commit', sha256: hash.digest('hex') });
|
|
47
|
+
} finally { closeSync(source); }
|
|
48
|
+
}
|
package/hermes-cli.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
|
|
2
2
|
// skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
|
|
3
3
|
// against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
|
|
4
|
-
// pinned `hermes` on its `PATH
|
|
5
|
-
// in `cron/executions.db`) the orchestrator records through Hermes's own function.
|
|
4
|
+
// pinned `hermes` on its `PATH`. Native cron incidents use the owning interchange codec, in the same executions store.
|
|
6
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
7
6
|
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
7
|
import { delimiter, dirname, join } from 'node:path';
|
|
9
8
|
import { parseDotenv } from './orchestration.mjs';
|
|
10
9
|
import { locateHermes } from './apply/doors.mjs';
|
|
10
|
+
import { recordCronIncident } from '@volter/supercode-interchange/cron-incidents';
|
|
11
|
+
import { redactText } from '@volter/teams/redact';
|
|
11
12
|
|
|
12
13
|
/** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
|
|
13
14
|
function python(install, code, payload, env, timeout) {
|
|
@@ -48,23 +49,10 @@ export function hermesToolEnv(profileDir) {
|
|
|
48
49
|
return env;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*/
|
|
56
|
-
export function recordIncident(profileDir, { job_id, job_name = null, error }) {
|
|
57
|
-
const install = hermesInstall();
|
|
58
|
-
if (!install?.python) return { id: null, why: 'no Hermes install on PATH' };
|
|
59
|
-
const code = 'import json,sys\nfrom cron.incidents import upsert_incident\na=json.loads(sys.stdin.read())\nprint(json.dumps(upsert_incident(a["job_id"], a["error"], job_name=a.get("job_name"))))';
|
|
60
|
-
const run = spawnSync(install.python, ['-c', code], {
|
|
61
|
-
input: JSON.stringify({ job_id, job_name, error: String(error ?? 'failed') }),
|
|
62
|
-
encoding: 'utf8', timeout: 30_000, cwd: install.source,
|
|
63
|
-
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source },
|
|
64
|
-
});
|
|
65
|
-
if (run.status !== 0) return { id: null, why: (run.stderr || run.error?.message || '').trim().slice(-300) };
|
|
66
|
-
const [id] = JSON.parse(run.stdout.trim().split('\n').pop());
|
|
67
|
-
return { id };
|
|
52
|
+
/** Record native cron failure grouping without requiring a separate Hermes Python installation. */
|
|
53
|
+
export function recordIncident(profileDir, { job_id, error }) {
|
|
54
|
+
try { return recordCronIncident(profileDir, { job_id, error: String(error ?? 'failed'), redacted_error: redactText(String(error ?? 'failed')) }); }
|
|
55
|
+
catch (error) { return { id: null, why: error.message }; }
|
|
68
56
|
}
|
|
69
57
|
|
|
70
58
|
// Access through Hermes's own pairing store and writers (docs/plans/hermes-compat.md row 8): a code is
|
package/index.d.ts
CHANGED
|
@@ -443,7 +443,7 @@ export interface McpServerLaunch { name: string; command: string; arguments: str
|
|
|
443
443
|
export function mountOrchestratorMcp(o: {
|
|
444
444
|
root: Path; profile: Profile; harness: string; surfaceKey?: string | null;
|
|
445
445
|
launch?: { program: string; arguments?: string[]; env?: Record<string, string> };
|
|
446
|
-
socketPath?: Path;
|
|
446
|
+
socketPath?: Path; program?: string;
|
|
447
447
|
/** The Teams id of the agent the session is; the rh2-agent record installed for that id (`--agent`) is mounted beside
|
|
448
448
|
* the orchestrator server as `rh2-agent`. */
|
|
449
449
|
agentId?: string | null;
|
package/loop.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
8
8
|
import { basename, join, dirname } from 'node:path';
|
|
9
|
+
import { parseSessionWire, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
|
|
9
10
|
import { step, leaseId } from './reducer.mjs';
|
|
10
11
|
import * as events from './events.mjs';
|
|
11
12
|
import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
|
|
@@ -278,9 +279,18 @@ export class Orchestrator {
|
|
|
278
279
|
try {event={...event,identity:await this.identityRecord.observe({observation,location:JSON.stringify(event.key),at:Date.parse(event.at),name:event.sender_name,evidence:{adapter:event.adapter,message_id:event.message_id}})};}
|
|
279
280
|
catch(error){event={...event,identity:{observation,principal:null,classification:'unresolved',label:'unresolved'}};this.log('warn',`identity observation unavailable: ${error.message}`);}
|
|
280
281
|
}
|
|
282
|
+
const before = parseSessionWire(stringifySessionWire(this.state));
|
|
281
283
|
const effects = step(this.state, event);
|
|
282
|
-
|
|
284
|
+
const firstSave = effects.findIndex(effect => effect.type === 'save');
|
|
285
|
+
if (firstSave >= 0) {
|
|
286
|
+
// Persist the reducer's claims before any external action. A refused save must leave no in-memory gate or
|
|
287
|
+
// hosted-fire marker behind: otherwise the next tick thinks an execution that never started is still alive.
|
|
288
|
+
try { await this.save(); }
|
|
289
|
+
catch (error) { this.state = before; throw error; }
|
|
290
|
+
}
|
|
291
|
+
for (const [index, effect] of effects.entries()) {
|
|
283
292
|
this.effectLog.push(effect);
|
|
293
|
+
if (index === firstSave) continue;
|
|
284
294
|
await this.perform(effect);
|
|
285
295
|
}
|
|
286
296
|
return effects;
|