@volter/supercode-orchestrator 0.5.205 → 0.5.207
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 +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +278 -97
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +305 -80
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +92 -27
- package/board/files.mjs +17 -3
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +8 -2
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/notices.mjs +5 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +84 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +96 -31
- package/board/ztrack.mjs +207 -32
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
package/board/doors.mjs
CHANGED
|
@@ -45,33 +45,63 @@ async function opened(machine) {
|
|
|
45
45
|
*/
|
|
46
46
|
export async function machineCall(machine, method, params = {}, { timeoutMs = 60_000 } = {}) {
|
|
47
47
|
if (!method.startsWith('harness.v1.')) throw new Error('a machine door is a harness.v1 method');
|
|
48
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('a machine door timeout is a positive finite number of milliseconds');
|
|
48
49
|
const started = performance.now();
|
|
50
|
+
const deadline = started + timeoutMs;
|
|
51
|
+
// The caller's bound covers admission too. Shared openings remain the owner's: a caller that stops waiting must
|
|
52
|
+
// not close another caller's channel/client. Once its admission wait expires, it never sends the method later.
|
|
53
|
+
const phase = async (name, work) => {
|
|
54
|
+
const at = performance.now();
|
|
55
|
+
const remaining = deadline - at;
|
|
56
|
+
const expired = () => Object.assign(new Error(`${method} exceeded ${timeoutMs} ms while waiting for ${name}${name === 'request' ? '; whether the method ran is unknown' : '; the method was not sent'}`), {
|
|
57
|
+
code: 'REQUEST_TIMEOUT', phase: name, requestNotSent: name !== 'request',
|
|
58
|
+
});
|
|
59
|
+
if (remaining <= 0) throw expired();
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
let timer;
|
|
62
|
+
try {
|
|
63
|
+
const timeout = new Promise((_, reject) => {
|
|
64
|
+
timer = setTimeout(() => { const error = expired(); controller.abort(error); reject(error); }, remaining);
|
|
65
|
+
});
|
|
66
|
+
return await Promise.race([work({ timeoutMs: remaining, signal: controller.signal }), timeout]);
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
noteStep(`door ${method}: ${name}`, performance.now() - at);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
49
72
|
let door = null;
|
|
50
73
|
try {
|
|
51
|
-
door = await opened(machine);
|
|
74
|
+
door = await phase('machine channel', () => opened(machine));
|
|
52
75
|
if (method.startsWith('harness.v1.sessions.')) {
|
|
53
76
|
// The harness client starts its transport again itself once it ends (a daemon restart there); one that never
|
|
54
77
|
// started is let go, so the next call starts another.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
const client = await phase('harness admission', () => {
|
|
79
|
+
if (!door.harness) {
|
|
80
|
+
const starting = (async () => { const client = new SupercodeHarnessClient({ transport: channelHarness(door.channel, machine) }); await client.start?.(); return client; })();
|
|
81
|
+
starting.catch(() => { if (door.harness === starting) door.harness = null; });
|
|
82
|
+
door.harness = starting;
|
|
83
|
+
}
|
|
84
|
+
return door.harness;
|
|
85
|
+
});
|
|
86
|
+
return await phase('request', (options) => client.request(method, params, options));
|
|
61
87
|
}
|
|
62
88
|
// The node stream ends with the daemon behind it (another machine's daemon restarting, an upgrade) while the channel
|
|
63
89
|
// to it stays open: it is used only while it is open, and opened again once it is not.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
const rpc = await phase('node admission', () => {
|
|
91
|
+
if (!door.node) {
|
|
92
|
+
const opening = door.channel.open('node').then((stream) => {
|
|
93
|
+
stream.on('close', () => { if (door.node === opening) door.node = null; });
|
|
94
|
+
return new StreamRpc(stream);
|
|
95
|
+
});
|
|
96
|
+
opening.catch(() => { if (door.node === opening) door.node = null; });
|
|
97
|
+
door.node = opening;
|
|
98
|
+
}
|
|
99
|
+
return door.node;
|
|
100
|
+
});
|
|
101
|
+
return await phase('request', (options) => rpc.call(method, params, options));
|
|
73
102
|
} catch (error) {
|
|
74
|
-
|
|
103
|
+
// An expired admission sent nothing and retains the shared opening. Retire only an owned remote request channel that stayed silent.
|
|
104
|
+
if (machine && door && error?.requestNotSent !== true && (error?.code === 'REQUEST_TIMEOUT' || error?.code === 504)) retireSilent(machine, door, started);
|
|
75
105
|
throw error;
|
|
76
106
|
} finally {
|
|
77
107
|
noteStep(`door ${method}${machine ? ' (another machine)' : ''}`, performance.now() - started);
|
package/board/engine.mjs
CHANGED
|
@@ -8,15 +8,16 @@
|
|
|
8
8
|
// Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
|
|
9
9
|
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
10
10
|
import { current } from './call.mjs';
|
|
11
|
+
import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
|
|
11
12
|
import { executionOf } from './session-work.mjs';
|
|
12
13
|
import { spawn, execFile } from 'node:child_process';
|
|
13
14
|
import { existsSync, rmSync } from 'node:fs';
|
|
14
15
|
import { join, resolve } from 'node:path';
|
|
15
16
|
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
|
|
16
17
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
-
import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
18
|
-
import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
19
|
-
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
18
|
+
import { view, actorOf, principalOf, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
19
|
+
import { assertSourceReceipt, completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
20
|
+
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
20
21
|
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
21
22
|
import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
|
|
22
23
|
|
|
@@ -37,11 +38,22 @@ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
|
|
|
37
38
|
const all = (db, sql, ...args) => db.prepare(sql).all(...args);
|
|
38
39
|
const run = (db, sql, ...args) => db.prepare(sql).run(...args);
|
|
39
40
|
|
|
41
|
+
// A card's number (#42): what the store gave it when it was made (store.mjs layOutNumbers). A person names a card by
|
|
42
|
+
// it wherever its id is taken (`42` or `#42`), and every view of a card shows it first, before the id.
|
|
43
|
+
/** Card `n`'s id on this board, or null when no card here has that number. */
|
|
44
|
+
export const cardByNumber = (db, n) => one(db, `SELECT t.id FROM ${CARD_NUMBERS} n JOIN tasks t ON t.id = n.task_id WHERE n.number = ?`, Number(n))?.id ?? null;
|
|
45
|
+
/** The number of card `id`, or null. */
|
|
46
|
+
export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
|
|
47
|
+
/** The numbers of cards `ids`: id → number. */
|
|
48
|
+
export const numbersOf = (db, ids) => new Map(ids.length ? all(db, `SELECT task_id, number FROM ${CARD_NUMBERS} WHERE task_id IN (SELECT value FROM json_each(?))`, JSON.stringify([...new Set(ids)])).map((r) => [r.task_id, r.number]) : []);
|
|
49
|
+
/** A card as a person reads it: its number first, then its id (`#42 t_4406c88a`). */
|
|
50
|
+
export const cardLabel = (number, id) => (number ? `#${number} ${id}` : String(id));
|
|
51
|
+
|
|
40
52
|
export function getTask(db, id) {
|
|
41
53
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
42
54
|
if (!row) return null;
|
|
43
55
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
44
|
-
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
|
|
56
|
+
return { ...row, number: numberOf(db, id), workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
|
|
45
57
|
tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), sponsor: ours?.sponsor ?? null, sponsor_evidence: json(ours?.sponsor_evidence, null), requested_by_unverified: json(ours?.requested_by_unverified, null) };
|
|
46
58
|
}
|
|
47
59
|
|
|
@@ -122,10 +134,11 @@ export function setRunSession(db, runId, session) {
|
|
|
122
134
|
|
|
123
135
|
/** The card's latest session the dispatcher recorded, newest run first; null when it never had one. */
|
|
124
136
|
export function lastSession(db, taskId, slot = 'worker') {
|
|
137
|
+
if (detachedCard(db, taskId)) return null;
|
|
125
138
|
// a session slot's own last session (runs recorded before slots existed are the worker's)
|
|
126
139
|
for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', taskId)) {
|
|
127
140
|
const s = json(row.metadata, {})?.supercode;
|
|
128
|
-
if (s?.session_id && (s.slot ?? 'worker') === slot) return s;
|
|
141
|
+
if (s?.session_id && (s.slot ?? 'worker') === slot) return detachedSession(db, s) ? null : s;
|
|
129
142
|
}
|
|
130
143
|
return null;
|
|
131
144
|
}
|
|
@@ -241,6 +254,9 @@ export function createCard(board, spec) {
|
|
|
241
254
|
if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
|
|
242
255
|
const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
|
|
243
256
|
const entry = workflowFor(board.root);
|
|
257
|
+
if (spec.reviewer != null && (typeof spec.reviewer !== 'string' || !spec.reviewer.trim() || !entry.profiles?.[spec.reviewer]?.worker?.harness)) {
|
|
258
|
+
throw new Error('A card reviewer names a runnable profile in this home.');
|
|
259
|
+
}
|
|
244
260
|
if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
|
|
245
261
|
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
246
262
|
let id = newTaskId();
|
|
@@ -281,7 +297,8 @@ export function createCard(board, spec) {
|
|
|
281
297
|
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
282
298
|
const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
|
|
283
299
|
every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
284
|
-
event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null,
|
|
300
|
+
event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null,
|
|
301
|
+
...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.sponsor ? { sponsor: spec.sponsor } : {}), ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
285
302
|
if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
|
|
286
303
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
287
304
|
if (arc) {
|
|
@@ -290,7 +307,7 @@ export function createCard(board, spec) {
|
|
|
290
307
|
event(db, arc.id, 'subtask_added', { subtask: id, title });
|
|
291
308
|
}
|
|
292
309
|
if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
|
|
293
|
-
else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
|
|
310
|
+
else if (initial === 'running') claimIn(db, id, { ttl: claimTtl(), workflow: entry.workflow });
|
|
294
311
|
// The creation declares its role even when dispatch is held. The existing durable effect queue
|
|
295
312
|
// keeps public-door I/O outside this transaction and retains an offline declaration for retry.
|
|
296
313
|
let declaration;
|
|
@@ -333,12 +350,13 @@ function wouldCycle(db, parent, child) {
|
|
|
333
350
|
* from a native binary released before its `set` (the orchestrator and the native install separately) still has a verb
|
|
334
351
|
* per property: it is answered so until that native is gone from the fleet, then this fallback is deleted (t_530ce43e).
|
|
335
352
|
*/
|
|
336
|
-
export const SETTABLE = ['assignee', 'machine', 'model', 'every'];
|
|
353
|
+
export const SETTABLE = ['assignee', 'machine', 'model', 'every', 'reviewer'];
|
|
337
354
|
export function set(board, id, field, value, { provider = null, roles } = {}) {
|
|
338
355
|
if (!SETTABLE.includes(field)) throw new Error(`set takes one of ${SETTABLE.join(', ')}, not ${field}`);
|
|
339
356
|
if (provider && (field !== 'model' || !value)) throw new Error('a provider goes with a model');
|
|
340
357
|
value = value || null;
|
|
341
358
|
if (workflowFor(board.root).workflow.verbs?.set) return act(board, id, 'set', { roles, payload: { field, value, provider } });
|
|
359
|
+
if (field === 'reviewer') throw new Error('This older workflow declares no reviewer setter; update its workflow before setting a card reviewer.');
|
|
342
360
|
const verb = { assignee: ['assign', { assignee: value }], machine: ['move', { machine: value }], model: ['set_model', { model: value, provider }], every: ['set_every', { every: value }] }[field];
|
|
343
361
|
return act(board, id, verb[0], { roles, payload: verb[1] });
|
|
344
362
|
}
|
|
@@ -347,11 +365,12 @@ export function set(board, id, field, value, { provider = null, roles } = {}) {
|
|
|
347
365
|
|
|
348
366
|
/** Claim a ready card: running, a claim held until `ttl` from now, a run opened. Answers the run id or null. */
|
|
349
367
|
export function claim(board, id, { ttl, lock = claimer(), expectStatus = 'ready' } = {}) {
|
|
350
|
-
|
|
368
|
+
const { workflow } = workflowFor(board.root);
|
|
369
|
+
return tx(board, (db) => claimIn(db, id, { ttl, lock, expectStatus, workflow }));
|
|
351
370
|
}
|
|
352
371
|
|
|
353
|
-
/** `claim` inside the caller's
|
|
354
|
-
export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null } = {}) {
|
|
372
|
+
/** Manual `claim` inside the caller's transaction; the declared stage and run commit together. */
|
|
373
|
+
export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null, workflow } = {}) {
|
|
355
374
|
if (getTask(db, id)?.subtask_of) throw new Error('a subtask is worked by its arc and has no run of its own');
|
|
356
375
|
// a triage card is claimed to be specified, which does not wait on its parents
|
|
357
376
|
if (expectStatus !== 'triage' && !parentsDone(db, id)) {
|
|
@@ -360,19 +379,22 @@ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStat
|
|
|
360
379
|
return null;
|
|
361
380
|
}
|
|
362
381
|
const task = must(db, id);
|
|
363
|
-
|
|
382
|
+
const sourceStatus = statusOf(db, task);
|
|
383
|
+
if (expectStatus && sourceStatus !== expectStatus) return null;
|
|
364
384
|
if (task.claim_lock) return null;
|
|
385
|
+
if (!workflow?.statuses?.running) throw new Error('manual claim needs a running status declared by this home\'s workflow');
|
|
365
386
|
const t = now();
|
|
366
387
|
if (task.current_run_id) {
|
|
367
388
|
run(db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
|
|
368
389
|
}
|
|
369
390
|
const expires = t + ttl;
|
|
370
|
-
|
|
391
|
+
setStatus(db, workflow, id, 'running');
|
|
392
|
+
run(db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, id);
|
|
371
393
|
const r = run(db, "INSERT INTO task_runs (task_id, profile, step_key, status, claim_lock, claim_expires, max_runtime_seconds, started_at) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)",
|
|
372
394
|
id, task.assignee, task.current_step_key, lock, expires, task.max_runtime_seconds, t);
|
|
373
395
|
const runId = Number(r.lastInsertRowid);
|
|
374
396
|
run(db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', runId, id);
|
|
375
|
-
event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: ['review', 'triage'].includes(
|
|
397
|
+
event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: ['review', 'triage'].includes(sourceStatus) ? sourceStatus : 'ready' }, runId);
|
|
376
398
|
return runId;
|
|
377
399
|
}
|
|
378
400
|
|
|
@@ -428,6 +450,7 @@ export function setCaller(fn) { current().roles = fn; }
|
|
|
428
450
|
const callerRoles = (db, cardId) => current().roles(db, cardId);
|
|
429
451
|
|
|
430
452
|
export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
|
|
453
|
+
assertSourceReceipt(payload);
|
|
431
454
|
const entry = await loadWorkflow(board.root);
|
|
432
455
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
433
456
|
refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
|
|
@@ -455,6 +478,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
455
478
|
}
|
|
456
479
|
|
|
457
480
|
export async function requestStateMany(board, ids, payload = {}) {
|
|
481
|
+
assertSourceReceipt(payload);
|
|
458
482
|
const targets = [...new Set(ids.map(String))];
|
|
459
483
|
if (!targets.length) throw new Error('A state request names at least one card.');
|
|
460
484
|
const entry = await loadWorkflow(board.root);
|
|
@@ -485,6 +509,7 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
485
509
|
|
|
486
510
|
/** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
|
|
487
511
|
export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
|
|
512
|
+
assertSourceReceipt(payload);
|
|
488
513
|
const entry = await loadWorkflow(board.root);
|
|
489
514
|
await refreshCardInputs(board, id, { commits: true });
|
|
490
515
|
await observeCompletionEvidence(board, id, entry, payload);
|
|
@@ -552,7 +577,17 @@ export async function runEffects(board, effects = [], { pending: due = false, el
|
|
|
552
577
|
// the claim: taken only while the row is due and not failed; another pass holding it answers no change
|
|
553
578
|
if (e.durableId && !tx(board, (db) => db.prepare('UPDATE supercode_effects SET next_at = ? WHERE id = ? AND failed_at IS NULL AND next_at <= ?').run(now() + EFFECT_CLAIM, e.durableId, now()).changes)) { did.claimed_elsewhere.push(e.durableId); continue; }
|
|
554
579
|
let done, error = null;
|
|
555
|
-
try {
|
|
580
|
+
try {
|
|
581
|
+
const effectSession = e.runId ? json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode : null;
|
|
582
|
+
done = e.id && e.kind !== 'board_message'
|
|
583
|
+
? await sessionOperation(board, { taskId: e.id, address: e.address ?? sessionAddress(effectSession), kind: `board effect ${e.kind}` }, () => runEffect(board, e))
|
|
584
|
+
: await runEffect(board, e);
|
|
585
|
+
} catch (thrown) {
|
|
586
|
+
// Pending work no longer owns the person's session or its workspace.
|
|
587
|
+
done = Boolean(thrown?.sessionDetached);
|
|
588
|
+
error = done ? null : String(thrown?.message ?? thrown).slice(0, 500);
|
|
589
|
+
if (done && e.id) tx(board, db => event(db, e.id, 'effect_passed', { effect: e.durableId ?? null, kind: e.kind, reason: 'card detached to a person' }, e.runId ?? null));
|
|
590
|
+
}
|
|
556
591
|
if (!e.durableId) continue;
|
|
557
592
|
if (done) { tx(board, (db) => db.prepare('DELETE FROM supercode_effects WHERE id = ?').run(e.durableId)); did.done.push(`${e.durableId}:${e.kind}`); continue; }
|
|
558
593
|
did.kept.push(e.durableId);
|
|
@@ -602,7 +637,7 @@ function stopWorker(board, runId) {
|
|
|
602
637
|
if (!runId) return;
|
|
603
638
|
const row = view(board, (db) => one(db, 'SELECT metadata, claim_lock FROM task_runs WHERE id = ?', runId));
|
|
604
639
|
const worker = json(row?.metadata, {})?.supercode;
|
|
605
|
-
if (worker?.surface === 'headless' && worker.pid) terminate(Number(worker.pid));
|
|
640
|
+
if (worker?.surface === 'headless' && worker.pid && !detachedSession(board.db, worker)) terminate(Number(worker.pid));
|
|
606
641
|
}
|
|
607
642
|
|
|
608
643
|
/**
|
|
@@ -652,14 +687,20 @@ function invalidateDescendants(db, board, id) {
|
|
|
652
687
|
|
|
653
688
|
// each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
|
|
654
689
|
|
|
655
|
-
export const complete = async (board, id,
|
|
656
|
-
|
|
690
|
+
export const complete = async (board, id, options = {}) => {
|
|
691
|
+
assertSourceReceipt(options);
|
|
692
|
+
const { result = null, summary = null, metadata = null, roles, runId = null, commit = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = options;
|
|
693
|
+
return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
|
|
694
|
+
};
|
|
657
695
|
// a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
|
|
658
696
|
export const block = async (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
|
|
659
697
|
await act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
|
|
660
698
|
export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
|
|
661
|
-
export const requestReview = async (board, id,
|
|
662
|
-
|
|
699
|
+
export const requestReview = async (board, id, options = {}) => {
|
|
700
|
+
assertSourceReceipt(options);
|
|
701
|
+
const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null } = options;
|
|
702
|
+
return await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head } });
|
|
703
|
+
};
|
|
663
704
|
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
|
664
705
|
export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
|
|
665
706
|
export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
|
|
@@ -752,11 +793,35 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
|
|
|
752
793
|
* starts, completion and blocks) or `worker` (told of updates to the card it works).
|
|
753
794
|
*/
|
|
754
795
|
export function subscribeIn(db, id, address, role = 'creator') {
|
|
796
|
+
if (personCustody(db, address)) return;
|
|
755
797
|
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
|
|
756
798
|
run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
757
799
|
VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
|
|
758
800
|
}
|
|
759
801
|
|
|
802
|
+
/** Reconcile only the current worker's audience, inside the binding transaction. */
|
|
803
|
+
export function replaceWorkerSubscriptions(db, id, address) {
|
|
804
|
+
const active = currentRun(db, id), session = active?.metadata?.supercode;
|
|
805
|
+
const bound = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
806
|
+
if (!address || bound !== address || active.ended_at) return false;
|
|
807
|
+
const replaces = (session.slot ?? 'worker') === 'worker';
|
|
808
|
+
const family = all(db, `WITH RECURSIVE family(task_id) AS (
|
|
809
|
+
SELECT ? UNION SELECT c.task_id FROM supercode_cards c JOIN family f ON c.subtask_of = f.task_id
|
|
810
|
+
) SELECT task_id FROM family`, id);
|
|
811
|
+
const removed = [];
|
|
812
|
+
for (const { task_id: card } of family) {
|
|
813
|
+
for (const old of replaces ? all(db, "SELECT chat_id,thread_id FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND json_extract(delivery_metadata,'$.role')='worker' AND chat_id!=?", card, address) : []) {
|
|
814
|
+
run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=? AND json_extract(delivery_metadata,'$.role')='worker'", card, old.chat_id, old.thread_id);
|
|
815
|
+
removed.push({ card, address: old.chat_id });
|
|
816
|
+
}
|
|
817
|
+
// INSERT OR IGNORE preserves an independent creator/manager subscription
|
|
818
|
+
// and the current worker's existing cursor on repeated reconciliation.
|
|
819
|
+
subscribeIn(db, card, address, 'worker');
|
|
820
|
+
}
|
|
821
|
+
if (removed.length) event(db, id, 'notice_subscriptions_replaced', { address, role: 'worker', superseded: removed, reason: 'the current worker binding replaced its audience' }, active.id);
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
|
|
760
825
|
export function subscribe(board, id, address, role = 'creator') {
|
|
761
826
|
return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role); });
|
|
762
827
|
}
|
|
@@ -767,9 +832,9 @@ export function unsubscribe(board, id, address) {
|
|
|
767
832
|
|
|
768
833
|
function dependencyRecipient(db, id) {
|
|
769
834
|
const owner = must(db, id);
|
|
770
|
-
if (owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
|
|
835
|
+
if (owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
|
|
771
836
|
const session = currentRun(db, id)?.metadata?.supercode ?? lastSession(db, id);
|
|
772
|
-
return session?.forgotten || session?.close_confirmed_at ? null : session?.address ?? null;
|
|
837
|
+
return session?.forgotten || session?.close_confirmed_at || detachedSession(db, session) ? null : session?.address ?? null;
|
|
773
838
|
}
|
|
774
839
|
|
|
775
840
|
export function subscribeDependency(board, ownerId, sourceId, { remove = false } = {}) {
|
|
@@ -800,7 +865,7 @@ export function reconcileDependencySubscriptions(db) {
|
|
|
800
865
|
const address = owner ? dependencyRecipient(db, owner.id) : null;
|
|
801
866
|
const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
|
|
802
867
|
const identity = [row.task_id, row.chat_id, row.thread_id];
|
|
803
|
-
if (!owner || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
|
|
868
|
+
if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
|
|
804
869
|
else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=? ${where}`, address, JSON.stringify({ ...metadata, waiting_recipient: false, failures: 0 }), ...identity);
|
|
805
870
|
else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
|
|
806
871
|
}
|
|
@@ -826,19 +891,19 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
|
|
|
826
891
|
// ------------------------------------------------------------------ reading
|
|
827
892
|
|
|
828
893
|
export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
|
|
829
|
-
const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
894
|
+
const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
|
|
830
895
|
WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
831
896
|
ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
|
|
832
897
|
return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }));
|
|
833
898
|
}
|
|
834
899
|
|
|
835
900
|
export function showCard(db, id) {
|
|
836
|
-
const task = must(db, id);
|
|
901
|
+
const task = { ...must(db, id), reviewer: reviewerOf(db, id) };
|
|
837
902
|
return {
|
|
838
903
|
task,
|
|
839
904
|
parents: parentsOf(db, id),
|
|
840
905
|
children: childrenOf(db, id),
|
|
841
|
-
subtasks: all(db,
|
|
906
|
+
subtasks: all(db, `SELECT t.id, n.number, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id),
|
|
842
907
|
comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
|
|
843
908
|
runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
|
|
844
909
|
events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
|
|
@@ -1011,7 +1076,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
|
|
|
1011
1076
|
const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
|
|
1012
1077
|
if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
|
|
1013
1078
|
}
|
|
1014
|
-
if (task.status === 'ready' && task.assignee) {
|
|
1079
|
+
if (!task.detached && task.status === 'ready' && task.assignee) {
|
|
1015
1080
|
const since = [...events].reverse().find((e) => ['created', 'promoted', 'unblocked', 'reclaimed', 'session_lost', 'promoted_manual'].includes(e.kind))?.created_at ?? task.created_at;
|
|
1016
1081
|
const age = t - since;
|
|
1017
1082
|
if (age >= strandedSeconds) {
|
package/board/files.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { fenced } from './fence.mjs';
|
|
13
13
|
import { markRemovedHeads } from './published.mjs';
|
|
14
|
+
import { CARD_NUMBERS } from './store.mjs';
|
|
14
15
|
import { createHash } from 'node:crypto';
|
|
15
16
|
import { join } from 'node:path';
|
|
16
17
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -98,6 +99,9 @@ class FilesBoard {
|
|
|
98
99
|
const state = this.stateText === null ? null : JSON.parse(this.stateText);
|
|
99
100
|
for (const sql of state?.schema ?? []) mem.exec(sql);
|
|
100
101
|
this.init(mem, { fresh: !state?.schema?.length });
|
|
102
|
+
// the schema as the directory keeps it: a layout this load added (a table, a column, the card numbers) is written to
|
|
103
|
+
// every card's file by the next write (persist)
|
|
104
|
+
this.keptSchema = state?.schema ?? [];
|
|
101
105
|
const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
|
|
102
106
|
const insert = (table, rows) => {
|
|
103
107
|
const have = columns(table);
|
|
@@ -108,17 +112,26 @@ class FilesBoard {
|
|
|
108
112
|
};
|
|
109
113
|
const previous = this.texts;
|
|
110
114
|
this.texts = new Map();
|
|
115
|
+
const cards = [];
|
|
111
116
|
for (const name of readdirSync(this.dir)) {
|
|
112
117
|
if (!name.endsWith('.json') || name.startsWith('_')) continue;
|
|
113
118
|
const text = readFileSync(join(this.dir, name), 'utf8');
|
|
114
119
|
const card = JSON.parse(text);
|
|
115
120
|
this.texts.set(card.id, text);
|
|
116
|
-
|
|
121
|
+
cards.push(card);
|
|
117
122
|
}
|
|
118
|
-
|
|
123
|
+
// The id counters first: a number handed out before stays handed out.
|
|
119
124
|
for (const { name, seq } of state?.sequence ?? []) {
|
|
120
125
|
if (mem.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name).changes === 0) mem.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
|
|
121
126
|
}
|
|
127
|
+
// Inserting a card's row numbers it when it has no number yet (store.mjs layOutNumbers): every number a card file
|
|
128
|
+
// holds loads first, so each card keeps its own and a new one is past them all, and then the cards load in the
|
|
129
|
+
// order they were made.
|
|
130
|
+
for (const card of cards) if (card.tables?.[CARD_NUMBERS]) insert(CARD_NUMBERS, card.tables[CARD_NUMBERS]);
|
|
131
|
+
const madeAt = (card) => Number(card.tables?.tasks?.[0]?.created_at ?? 0);
|
|
132
|
+
cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
|
|
133
|
+
for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
|
|
134
|
+
for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
|
|
122
135
|
// Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
|
|
123
136
|
// those marks: its next write compares each card with its published head once (a named limit: each projection opened
|
|
124
137
|
// compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
|
|
@@ -172,7 +185,8 @@ class FilesBoard {
|
|
|
172
185
|
/** Write the files of the cards the transaction touched, then `_board.json`; writes compare the content hash. */
|
|
173
186
|
persist(schemaBefore) {
|
|
174
187
|
const schema = this.schema();
|
|
175
|
-
|
|
188
|
+
// reshaped by this transaction, or by the load it runs on (a layout the directory does not keep yet)
|
|
189
|
+
const reshaped = schema.join('\n') !== schemaBefore.join('\n') || schema.join('\n') !== (this.keptSchema ?? []).join('\n');
|
|
176
190
|
const touched = new Set(this.mem.prepare('SELECT DISTINCT id FROM _touched').all().map((r) => r.id).filter((id) => id !== null));
|
|
177
191
|
if (reshaped) this.watch(); // a table made in the transaction gets its triggers; every card is written again
|
|
178
192
|
const keys = this.keys;
|