@volter/supercode-orchestrator 0.5.209 → 0.5.211
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 +1 -1
- package/board/agent-declarations.mjs +18 -1
- package/board/call.mjs +2 -1
- package/board/capacity.mjs +54 -41
- package/board/ci.mjs +58 -29
- package/board/cli.mjs +39 -5
- package/board/credential-guard.mjs +15 -2
- package/board/dispatch.mjs +37 -19
- package/board/engine.mjs +5 -3
- package/board/event-stream.mjs +38 -4
- package/board/fleet-listeners.mjs +16 -5
- package/board/published.mjs +18 -17
- package/board/queries.mjs +30 -0
- package/board/store.mjs +16 -0
- package/board/workflow.mjs +16 -7
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -19,6 +19,16 @@ stage's own transition, which ends the run in the default workflow. See the
|
|
|
19
19
|
[private manual reading](../../docs/evidence/board-manual-claim-2026-10-09.md)
|
|
20
20
|
for scope and limits; existing inconsistent claims are not repaired automatically.
|
|
21
21
|
|
|
22
|
+
Completing a task with `--commit` inherits its arc's workspace when the task is
|
|
23
|
+
scratch. When that workspace belongs to another machine, the board verifies the
|
|
24
|
+
commit against the declared repository's pushed branch head through GitHub,
|
|
25
|
+
without requiring the checkout on the board host. A legacy directory path is
|
|
26
|
+
resolved through its own machine's workspace door to obtain that identity.
|
|
27
|
+
Remote commits take a full SHA or at least seven hexadecimal characters; a
|
|
28
|
+
different head or an unread remote refuses completion. Local checkouts keep
|
|
29
|
+
their existing Git commit resolution. This source check does not replace the
|
|
30
|
+
arc's independent review.
|
|
31
|
+
|
|
22
32
|
The orchestrator's runtime over the ontology: one typed operational model
|
|
23
33
|
(`OrchestratorState` = an orchestration value plus a runtime half) whose folder is its serialization.
|
|
24
34
|
`docs/ORCHESTRATOR-IR.md` is normative. The orchestration value and every codec are the ontology's, in
|
|
@@ -125,7 +135,11 @@ redacted refusal even when a failed call is fast. See
|
|
|
125
135
|
Board discovery caches advisory reads while waiting. Review requests, verdicts
|
|
126
136
|
and acceptance refresh the complete source set, including previously draft or
|
|
127
137
|
closed declared sources, and validate its current heads. An unread set defers
|
|
128
|
-
the action.
|
|
138
|
+
the action. Before the dispatcher spends those validation reads, it checks the
|
|
139
|
+
advisory candidate against its local worktrees. A mismatched HEAD or uncommitted
|
|
140
|
+
work refuses that attempt locally. Each later round rechecks the checkout, so
|
|
141
|
+
a repair wakes it; a successful preflight still needs the fresh complete-source,
|
|
142
|
+
local and pushed-head checks before review is recorded. Unread commit dates also defer eligibility and are never cached as
|
|
129
143
|
old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
|
|
130
144
|
reserves its own attempt. All processes and credentials sharing this supercode
|
|
131
145
|
home share a rolling-hour ceiling of 1,200 attempts, including at most 600
|
package/agents.mjs
CHANGED
|
@@ -232,8 +232,17 @@ export function localAgentRecord(root,org,id){
|
|
|
232
232
|
/** Rename only identities this card role declared; a reassigned standing agent keeps its own name. */
|
|
233
233
|
export function renameCardAgent(root, assignment, readableId) {
|
|
234
234
|
const intent=agentIntent(root,assignment.org,assignment.agent_id);
|
|
235
|
-
if(!intent
|
|
236
|
-
|
|
235
|
+
if(!intent||assignment.state!=='active')return false;
|
|
236
|
+
let declaration=intent.declaration_json?JSON.parse(intent.declaration_json):null;
|
|
237
|
+
if(!declaration){
|
|
238
|
+
// Older declared intents kept only their stable key. Recover this role's own current identity, never an agent
|
|
239
|
+
// reassigned from another card or a standing profile. The existing Teams identity keeps its lineage and sessions.
|
|
240
|
+
const suffix=`/card/${assignment.target}/${assignment.role}`,base=intent.key.replace(/\/generation\/\d+$/,'');
|
|
241
|
+
if(!base.endsWith(suffix)||declarationHead(root,assignment.org,base)!==assignment.agent_id)return false;
|
|
242
|
+
const parent=declarationHead(root,assignment.org,`${base.slice(0,-suffix.length)}/root`);
|
|
243
|
+
if(!parent)return false;
|
|
244
|
+
declaration={kind:'card',card:assignment.target,role:assignment.role,profile:intent.profile_ref,parent};
|
|
245
|
+
}
|
|
237
246
|
if(declaration.kind!=='card'||declaration.card!==assignment.target||declaration.role!==assignment.role)return false;
|
|
238
247
|
const name=`${assignment.role}-${readableId}`;
|
|
239
248
|
if(declaration.name===name&&declaration.readableId===readableId)return false;
|
package/bin/orchestrator.mjs
CHANGED
|
@@ -34,7 +34,7 @@ if (process.argv[2] === 'workflow') {
|
|
|
34
34
|
catch(error) {noteCommandError(error);process.stderr.write(`supercode workflow: ${error.message??error}\n${error?.errcode!=null&&error.stack?`${error.stack}\n`:''}`);code=1;}
|
|
35
35
|
// the board documents' git work this command queued (a ztrack board's commit and push) is finished before it exits
|
|
36
36
|
try{const{settleDocuments}=await import('../board/git-document.mjs');await settleDocuments();}catch{/* nothing queued */}
|
|
37
|
-
await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.write('',done))));
|
|
37
|
+
await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.destroyed?done():stream.write('',done))));
|
|
38
38
|
process.exit(code);
|
|
39
39
|
} else if (process.argv[2] === 'connect') {
|
|
40
40
|
const {parseArgs}=await import('node:util');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Consume the card-creation intent after the board transaction; retries reuse the daemon-minted id. The card's agent is
|
|
2
2
|
// minted and recorded here, and its declaration handed to this machine's daemon, which declares it in Teams: whoever ran
|
|
3
3
|
// the effect (the creating command, or a delivery pass retrying it) makes no Teams call (D137).
|
|
4
|
-
import { agentScope, localCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
|
|
4
|
+
import { agentScope, localCardAgent, renameCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
|
|
5
5
|
import { cardHandleOf, readableIdOf, event, json, tx, view } from './store.mjs';
|
|
6
6
|
import { workflowFor } from './workflow.mjs';
|
|
7
7
|
import { cardAgentFacts, cardAgentReceiptRecorded, syncCardAgentScope } from './published.mjs';
|
|
@@ -47,6 +47,23 @@ export async function declareCardAgentEffect(board,effect) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** A readable-id edit carries every live role's retained declaration through the existing machine identity door.
|
|
51
|
+
* No assignment is created or revived here; an ended or reassigned agent keeps its own identity and history. */
|
|
52
|
+
export async function renameCardAgentsEffect(board,effect) {
|
|
53
|
+
const task=view(board,db=>db.prepare('SELECT t.readable_id,t.status,c.subtask_of,c.archived FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
|
|
54
|
+
if(!task?.readable_id||task.subtask_of||task.archived||['done','cancelled','archived'].includes(task.status))return true;
|
|
55
|
+
const agents=new Set();
|
|
56
|
+
for(const assignment of cardAgentAssignments(board.root,effect.id)) {
|
|
57
|
+
if(assignment.state!=='active'||!admitsScope(board,effect.id,assignment.org))continue;
|
|
58
|
+
renameCardAgent(board.root,assignment,task.readable_id);
|
|
59
|
+
agents.add(assignment.agent_id);
|
|
60
|
+
}
|
|
61
|
+
if(!agents.size)return true;
|
|
62
|
+
const pending=(await handOffAgents(board.root)).filter(item=>item.agent==='*'||agents.has(item.agent));
|
|
63
|
+
if(pending.length)throw new Error(pending.map(item=>item.error).join('; '));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/** A failed board write leaves the owner's receipt pending. A failed acknowledgement repeats no board event. */
|
|
51
68
|
export function recordCardAgentChanges(board,target){
|
|
52
69
|
const changes=pendingCardAgentChanges(board.root,target);
|
package/board/call.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// a connection of its own (store.mjs openBoard), and nothing is written
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
18
|
+
import { withCredentialGuard } from './credential-guard.mjs';
|
|
18
19
|
|
|
19
20
|
const calls = new AsyncLocalStorage();
|
|
20
21
|
// what a call learns as it runs, and the workflow it read (pinned: a reload while it runs does not change its basis)
|
|
@@ -34,7 +35,7 @@ export function within({ caller = null, facts = {}, cwd, stdin = null, out, err,
|
|
|
34
35
|
/** Run `fn` as a unit of the process's own work (a round, a delivery pass): the process's identity and output, with
|
|
35
36
|
* what it learns and the workflow it read kept to itself. */
|
|
36
37
|
export function ownCall(fn) {
|
|
37
|
-
return calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn);
|
|
38
|
+
return withCredentialGuard(() => calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn));
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** The caller as its address names it, or null. */
|
package/board/capacity.mjs
CHANGED
|
@@ -89,50 +89,63 @@ export async function holdings(board, options) {
|
|
|
89
89
|
*/
|
|
90
90
|
async function readHoldings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null, runLaneOf = null }, unchanged) {
|
|
91
91
|
const out = [];
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
let cursor = '';
|
|
93
|
+
const high = board.db.prepare('SELECT MAX(id) AS id FROM tasks').get()?.id ?? '';
|
|
94
|
+
for (;;) {
|
|
94
95
|
await yieldToOwner();
|
|
95
96
|
if (!unchanged()) return null;
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
97
|
+
// Limit candidates before filtering or joining: even a page containing only archived/detached cards is bounded.
|
|
98
|
+
const candidates = board.db.prepare('SELECT id FROM tasks WHERE id>? AND id<=? ORDER BY id LIMIT 32').all(cursor, high);
|
|
99
|
+
if (!candidates.length) break;
|
|
100
|
+
cursor = candidates.at(-1).id;
|
|
101
|
+
const rows = board.db.prepare(`SELECT t.id,t.status,t.assignee,t.current_run_id,t.model_override,c.machine,c.subtask_of
|
|
102
|
+
FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
103
|
+
WHERE t.id IN (SELECT value FROM json_each(?)) AND t.status!='archived' AND c.detached IS NULL`)
|
|
104
|
+
.all(JSON.stringify(candidates.map(row => row.id)));
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
await yieldToOwner();
|
|
107
|
+
if (!unchanged()) return null;
|
|
108
|
+
const lane = lanes[row.assignee] ?? {};
|
|
109
|
+
const card = {
|
|
110
|
+
card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
|
|
111
|
+
// what the board stores for an arc is read through its workflow's stored_as rules now, never the column a write has
|
|
112
|
+
// not yet brought up to date (a paused card counted as running until its row caught up)
|
|
113
|
+
stored: !row.subtask_of && storedOf ? storedOf(row.id) : row.status,
|
|
114
|
+
// a card that names no machine runs on this home's: a cap on this machine counts it
|
|
115
|
+
machine: row.machine ? machineOf(row.machine) : home,
|
|
116
|
+
model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
|
|
117
|
+
};
|
|
118
|
+
// a session's model: what its transcript reports, else what its launch resolved, else the card's
|
|
119
|
+
const sessionOf = (s, slot) => {
|
|
120
|
+
const configured = s.model ?? card.model;
|
|
121
|
+
return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
|
|
122
|
+
dispatch: dispatched(s), run: s.run ?? null,
|
|
123
|
+
machine: s.surface === 'headless' ? home : s.machine || s.surface ? machineOf(s.machine ?? row.machine ?? home)
|
|
124
|
+
: executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
|
|
125
|
+
model: (s.session_id ? modelOf(s) : null) ?? configured };
|
|
126
|
+
};
|
|
127
|
+
const sessions = fleetOk ? await sessionsOf(board.db, row.id, unchanged) : new Map();
|
|
128
|
+
if (sessions === null) return null;
|
|
129
|
+
const held = [];
|
|
130
|
+
let current = null;
|
|
131
|
+
if (row.current_run_id) {
|
|
132
|
+
current = { ...json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode, run: row.current_run_id };
|
|
133
|
+
out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
|
|
134
|
+
}
|
|
135
|
+
for (const [slot, s] of sessions) {
|
|
136
|
+
if (!alive(s)) continue;
|
|
137
|
+
const session = sessionOf(s, slot);
|
|
138
|
+
held.push(session);
|
|
139
|
+
if (IN_TURN.has(liveRecord(s)?.status)) out.push({ ...session, counts: 'turns' });
|
|
140
|
+
}
|
|
141
|
+
// a live run whose session the fleet does not list yet (launching) holds a session's place already
|
|
142
|
+
if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
|
|
143
|
+
out.push(...held);
|
|
144
|
+
// the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
|
|
145
|
+
// until it is done); its model and harness are its run's session's, else its newest live session's, else its own
|
|
146
|
+
// a subtask is a task of its arc: it never runs or holds a slot of its own
|
|
147
|
+
if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
|
|
128
148
|
}
|
|
129
|
-
// a live run whose session the fleet does not list yet (launching) holds a session's place already
|
|
130
|
-
if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
|
|
131
|
-
out.push(...held);
|
|
132
|
-
// the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
|
|
133
|
-
// until it is done); its model and harness are its run's session's, else its newest live session's, else its own
|
|
134
|
-
// a subtask is a task of its arc: it never runs or holds a slot of its own
|
|
135
|
-
if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
|
|
136
149
|
}
|
|
137
150
|
return out;
|
|
138
151
|
}
|
package/board/ci.mjs
CHANGED
|
@@ -291,13 +291,15 @@ async function repositoryWorkspace(board, id) {
|
|
|
291
291
|
// the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
|
|
292
292
|
const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
|
|
293
293
|
const here = isLocalWorkspace(workspace);
|
|
294
|
+
if (!here) {
|
|
295
|
+
const identity = workspace.workspace_anchor ?? workspace.workspace_path;
|
|
296
|
+
if (!identity) return null;
|
|
297
|
+
// A portable remote declaration supplies the source identity. Legacy paths
|
|
298
|
+
// are resolved by their own machine, never read as paths on the board host.
|
|
299
|
+
const resolved = plainWorkspace(workspace.workspace_kind, identity, workspace.branch_name) ?? await resolveWorkspace(workspace, false);
|
|
300
|
+
return { ...resolved, branch: workspace.branch_name ?? resolved.branch, here: false };
|
|
301
|
+
}
|
|
294
302
|
if (workspace.workspace_anchor) {
|
|
295
|
-
// A portable remote declaration supplies the source identity. Reviewing
|
|
296
|
-
// its pushed head does not require that machine to keep the old checkout.
|
|
297
|
-
if (!here) {
|
|
298
|
-
const declared = plainWorkspace(workspace.workspace_kind, workspace.workspace_anchor, workspace.branch_name);
|
|
299
|
-
if (declared) return { ...declared, here: false };
|
|
300
|
-
}
|
|
301
303
|
const resolved = await resolveWorkspace(workspace, false);
|
|
302
304
|
// Resolution without create names a solo worktree even before it exists on
|
|
303
305
|
// this machine. Its portable repository and branch still identify the
|
|
@@ -305,7 +307,24 @@ async function repositoryWorkspace(board, id) {
|
|
|
305
307
|
if (here && !checkoutPresent(resolved.path)) return { ...resolved, here: await recreatedWorktree(workspace, resolved) };
|
|
306
308
|
return { ...resolved, here };
|
|
307
309
|
}
|
|
308
|
-
return workspace.workspace_path ? { path: workspace.workspace_path, here:
|
|
310
|
+
return workspace.workspace_path ? { path: workspace.workspace_path, here: checkoutPresent(workspace.workspace_path) } : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A supplied completion commit belongs to the workspace's machine. Local Git
|
|
314
|
+
* keeps its existing semantics; a remote declaration is read at its pushed head. */
|
|
315
|
+
async function completionSource(workspace, supplied) {
|
|
316
|
+
if (workspace?.here && workspace.path) {
|
|
317
|
+
return { repository: await originOf(workspace.path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], workspace.path) };
|
|
318
|
+
}
|
|
319
|
+
if (!workspace?.repository || !workspace.branch) throw new Error('The named commit needs a declared repository and branch, resolved through the workspace machine when only a path is recorded.');
|
|
320
|
+
if (typeof supplied !== 'string' || !/^[a-f0-9]{7,40}$/i.test(supplied)) throw new Error('Name the remote completion commit with its full SHA or an abbreviation of at least seven hexadecimal characters.');
|
|
321
|
+
// Read the declared branch, not an arbitrary commit that merely exists in
|
|
322
|
+
// the repository. No checkout or daemon upgrade on the board host is needed.
|
|
323
|
+
const pushed = await github(`/repos/${workspace.repository}/git/ref/heads/${encodeURIComponent(workspace.branch)}`);
|
|
324
|
+
const head = pushed?.object?.type === 'commit' ? pushed.object.sha : null;
|
|
325
|
+
if (typeof head !== 'string' || !/^[a-f0-9]{40}$/.test(head)) throw new Error(`The declared remote ${workspace.repository}:${workspace.branch} answered no commit head.`);
|
|
326
|
+
if (!head.startsWith(supplied.toLowerCase())) throw new Error(`The named commit ${supplied} must be the pushed head of ${workspace.repository}:${workspace.branch}, currently ${head}.`);
|
|
327
|
+
return { repository: workspace.repository, branch: workspace.branch, commit: head, source_sha: head };
|
|
309
328
|
}
|
|
310
329
|
|
|
311
330
|
/** A card's declared solo worktree that is missing here (deleted, or never made on this machine) while its branch is on
|
|
@@ -383,7 +402,7 @@ function registeredBranches(board, id) {
|
|
|
383
402
|
* declaration (engine.comment), the arc's complete set. A multi-repository arc carries them on the arc card, and its
|
|
384
403
|
* review discovers and accepts PRs from them with no subtask registered in that repository (VO-2858, VO-2860). */
|
|
385
404
|
export function arcSources(db, id) {
|
|
386
|
-
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
405
|
+
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
387
406
|
let declared = [];
|
|
388
407
|
try { declared = row ? JSON.parse(row.payload).branches : []; } catch { /* an unreadable declaration names none */ }
|
|
389
408
|
return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
|
|
@@ -396,8 +415,9 @@ export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/
|
|
|
396
415
|
/** The branch names an arc declares as its work: its own and its subtasks' `--branch`, the branches it declares as its
|
|
397
416
|
* sources (arcSources), and each branch its latest review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
|
|
398
417
|
export function declaredWorkBranches(db, id) {
|
|
399
|
-
const declared = db.prepare(`SELECT
|
|
400
|
-
|
|
418
|
+
const declared = db.prepare(`SELECT branch_name FROM tasks WHERE id=? AND status!='cancelled' AND branch_name IS NOT NULL
|
|
419
|
+
UNION SELECT t.branch_name FROM supercode_cards c CROSS JOIN tasks t ON t.id=c.task_id
|
|
420
|
+
WHERE c.subtask_of=? AND t.status!='cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
|
|
401
421
|
declared.push(...arcSources(db, id).map((b) => b.branch));
|
|
402
422
|
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
|
|
403
423
|
let reviewed = [];
|
|
@@ -410,7 +430,7 @@ export function declaredWorkBranches(db, id) {
|
|
|
410
430
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
411
431
|
* checked out (or no branch), is not a work branch: such a card's PRs are only its `wt/<card>` and registered ones, and with none it
|
|
412
432
|
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
413
|
-
export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
|
|
433
|
+
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false } = {}) {
|
|
414
434
|
const own = await repositoryWorkspace(board, id);
|
|
415
435
|
const registered = registeredBranches(board, id);
|
|
416
436
|
if (!own && !registered.length) return null;
|
|
@@ -450,7 +470,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
450
470
|
if (branch === list.defaultBranch) branch = null;
|
|
451
471
|
}
|
|
452
472
|
// a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
|
|
453
|
-
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
473
|
+
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
454
474
|
// A manual review and merge during a dispatcher outage may predate any
|
|
455
475
|
// board request. The source-review door already accepts merged heads; make
|
|
456
476
|
// them discoverable so the ordinary independent verdict can be retained.
|
|
@@ -467,7 +487,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
467
487
|
&& pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
|
|
468
488
|
});
|
|
469
489
|
for (const history of histories) for (const pr of history) {
|
|
470
|
-
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
490
|
+
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
471
491
|
}
|
|
472
492
|
return prs;
|
|
473
493
|
});
|
|
@@ -481,9 +501,13 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
481
501
|
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
|
|
482
502
|
for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
|
|
483
503
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
484
|
-
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
504
|
+
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
485
505
|
}
|
|
486
506
|
if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
|
|
507
|
+
// Advisory facts can refuse an attempt before its authoritative REST scan. Recheck the local checkout each
|
|
508
|
+
// round, so a repaired HEAD or clean worktree wakes it without caching the refusal. This is never evidence
|
|
509
|
+
// for a transition: verifyPullRequest repeats the checks against fresh metadata and the pushed head below.
|
|
510
|
+
if (checkWorkspaces) await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
|
|
487
511
|
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
|
|
488
512
|
}
|
|
489
513
|
|
|
@@ -636,9 +660,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
636
660
|
if (stage === 'completion' || stage === 'acceptance') {
|
|
637
661
|
const supplied = payload.commit ?? payload.metadata?.commit;
|
|
638
662
|
if (supplied) {
|
|
639
|
-
|
|
640
|
-
if (!path) throw new Error('The named commit needs a resolved repository workspace.');
|
|
641
|
-
Object.assign(state, { repository: await originOf(path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
|
|
663
|
+
Object.assign(state, await completionSource(await repositoryWorkspace(board, id), supplied));
|
|
642
664
|
}
|
|
643
665
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
644
666
|
if (candidate) {
|
|
@@ -712,14 +734,28 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
712
734
|
return state;
|
|
713
735
|
}
|
|
714
736
|
|
|
715
|
-
/**
|
|
716
|
-
async function
|
|
717
|
-
|
|
718
|
-
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
737
|
+
/** Local review readiness, also usable with advisory metadata to refuse before spending REST admission. */
|
|
738
|
+
async function requireReviewWorkspaceHead(board, id, own, observed, head, { pushed = false } = {}) {
|
|
739
|
+
const url = observed.url, repository = repositoryOf(url), branch = observed.headRefName;
|
|
719
740
|
// An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
|
|
720
741
|
// card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
|
|
721
742
|
// with nothing uncommitted. A card on another machine is reviewed at its pushed head: its checkout is not read from here.
|
|
722
743
|
const path = observed.state === 'MERGED' || (own && !own.here) ? null : !own ? await branchCheckout(board, id, repository, observed.headRefName) : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
|
|
744
|
+
if (!path) return;
|
|
745
|
+
await run('git', ['check-ref-format', '--branch', branch], path);
|
|
746
|
+
if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
|
|
747
|
+
const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
|
|
748
|
+
if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
|
|
749
|
+
if (pushed) {
|
|
750
|
+
const remote = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
|
|
751
|
+
if (remote !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
|
|
756
|
+
async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
|
|
757
|
+
if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
|
|
758
|
+
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
723
759
|
// A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
|
|
724
760
|
// verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
|
|
725
761
|
const merged = observed.state === 'MERGED';
|
|
@@ -732,14 +768,7 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, ob
|
|
|
732
768
|
const mainBranch = observed.defaultBranch ?? 'main';
|
|
733
769
|
// A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
|
|
734
770
|
if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
|
|
735
|
-
if (stage === 'request_review'
|
|
736
|
-
await run('git', ['check-ref-format', '--branch', branch], path);
|
|
737
|
-
if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
|
|
738
|
-
const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
|
|
739
|
-
if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
|
|
740
|
-
const pushed = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
|
|
741
|
-
if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
|
|
742
|
-
}
|
|
771
|
+
if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
|
|
743
772
|
return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
|
|
744
773
|
}
|
|
745
774
|
|
package/board/cli.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { taskStatusOf } from './published.mjs';
|
|
|
16
16
|
import { executionOf } from './session-work.mjs';
|
|
17
17
|
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
18
18
|
import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
|
|
19
|
-
import { redactText } from '
|
|
19
|
+
import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
|
|
20
20
|
import { openOwnerDoor } from './owner-door.mjs';
|
|
21
21
|
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
22
22
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
@@ -1749,8 +1749,11 @@ async function answerVerbs(root, log) {
|
|
|
1749
1749
|
* absence from a slow-only log cannot prove those calls met the time bound. */
|
|
1750
1750
|
export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
1751
1751
|
const scoped = typeof slowLog.withStepScope === 'function';
|
|
1752
|
+
// The owner's receipt includes asynchronous custody preparation too: it is part of the caller's wait.
|
|
1753
|
+
const began = performance.now(), before = slowLog.processCounters?.() ?? null;
|
|
1754
|
+
let prepared = false;
|
|
1752
1755
|
const run = async () => {
|
|
1753
|
-
|
|
1756
|
+
prepared = true;
|
|
1754
1757
|
let named = null, by = null, answer = null, outcome = 'error';
|
|
1755
1758
|
try {
|
|
1756
1759
|
answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
|
|
@@ -1769,7 +1772,23 @@ export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
|
1769
1772
|
}
|
|
1770
1773
|
}
|
|
1771
1774
|
};
|
|
1772
|
-
|
|
1775
|
+
const guarded = async () => {
|
|
1776
|
+
try { return await withCredentialGuard(run); }
|
|
1777
|
+
catch (error) {
|
|
1778
|
+
if (!prepared) {
|
|
1779
|
+
const ended = performance.now(), ms = ended - began;
|
|
1780
|
+
const code = error?.code === 'custody_unreadable' ? 'custody_unreadable' : 'custody_preparation_failed';
|
|
1781
|
+
// No request, caller payload or error text is inspected before custody is available. Fixed metadata needs
|
|
1782
|
+
// no redaction read and still records the failed preparation in this request's own step scope.
|
|
1783
|
+
recordSlow({ kind: 'call', name: 'owner verb custody preparation', args: [], ms, outcome: code,
|
|
1784
|
+
slowest: null, force: true, includeFast: true,
|
|
1785
|
+
extra: { failure: { code, phase: 'custody preparation' }, step_scope: scoped ? 'request' : 'process_interval',
|
|
1786
|
+
steps: scoped ? takeSteps() : slowLog.stepsWithin?.(began, ended) ?? null } });
|
|
1787
|
+
}
|
|
1788
|
+
throw error;
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
return scoped ? slowLog.withStepScope(guarded) : guarded();
|
|
1773
1792
|
}
|
|
1774
1793
|
|
|
1775
1794
|
/** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
|
|
@@ -1949,7 +1968,20 @@ async function serve(root, args = {}) {
|
|
|
1949
1968
|
let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
|
|
1950
1969
|
const listenerErrors = new Set();
|
|
1951
1970
|
const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
|
|
1952
|
-
const scanListeners = ({ waiting = true } = {}) =>
|
|
1971
|
+
const scanListeners = async ({ waiting = true } = {}) => {
|
|
1972
|
+
if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
|
|
1973
|
+
const revision = listenerRevision;
|
|
1974
|
+
try {
|
|
1975
|
+
await ownCall(() => {
|
|
1976
|
+
if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
|
|
1977
|
+
observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
|
|
1978
|
+
});
|
|
1979
|
+
} catch {
|
|
1980
|
+
// Every caller may schedule this without awaiting it. Preparation refusal leaves source cursors untouched;
|
|
1981
|
+
// the next admitted scan retries the retained observations, while the owner's public door stays available.
|
|
1982
|
+
listenerLog('fleet listener scan refused; retained observations wait for a prepared owner call');
|
|
1983
|
+
}
|
|
1984
|
+
};
|
|
1953
1985
|
const readListenerRecords = () => {
|
|
1954
1986
|
if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
|
|
1955
1987
|
const revision = listenerRevision;
|
|
@@ -1964,7 +1996,9 @@ async function serve(root, args = {}) {
|
|
|
1964
1996
|
};
|
|
1965
1997
|
fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
|
|
1966
1998
|
file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
|
|
1967
|
-
|
|
1999
|
+
// The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
|
|
2000
|
+
enrich: event => enrichFleetEvent(root, event),
|
|
2001
|
+
work: ownCall,
|
|
1968
2002
|
retry: () => scanListeners({ waiting: false }),
|
|
1969
2003
|
});
|
|
1970
2004
|
const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
|
|
@@ -3,7 +3,20 @@
|
|
|
3
3
|
// gitleaks' rule set and this machine's custody values), plus the shape of an environment listing: several NAME=value
|
|
4
4
|
// lines in a row. A shell that expanded `$(env)` or a pasted .env into a comment once put live keys on a card, where
|
|
5
5
|
// every reader, every launch prompt and the board's own remote received them.
|
|
6
|
-
import { redactText } from '@volter/teams/redact';
|
|
6
|
+
import { redactText, redactionSnapshotAsync, withRedactionSnapshot } from '@volter/teams/redact';
|
|
7
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
8
|
+
|
|
9
|
+
/** One fresh, fail-closed custody observation per call/round, read before its synchronous guards. No role decision
|
|
10
|
+
* is kept here, and a concurrent caller cannot replace this call's redaction basis. */
|
|
11
|
+
export async function withCredentialGuard(fn) {
|
|
12
|
+
const began = performance.now();
|
|
13
|
+
let snapshot;
|
|
14
|
+
try { snapshot = await redactionSnapshotAsync(process.env, { fresh: true }); }
|
|
15
|
+
finally { noteStep('custody observation', performance.now() - began); }
|
|
16
|
+
return withRedactionSnapshot(process.env, snapshot, fn);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const redactGuardText = text => redactText(text);
|
|
7
20
|
|
|
8
21
|
const ENV_LINE = /^\s*(?:export\s+|declare\s+-x\s+)?[A-Z][A-Z0-9_]{2,}=\S/;
|
|
9
22
|
const ENV_LINES = 5;
|
|
@@ -13,7 +26,7 @@ export function credentialDumpIn(text) {
|
|
|
13
26
|
if (typeof text !== 'string' || text.length < 8) return null;
|
|
14
27
|
const lines = text.split('\n').filter((line) => ENV_LINE.test(line)).length;
|
|
15
28
|
if (lines >= ENV_LINES) return `${lines} NAME=value lines, the shape of an environment listing`;
|
|
16
|
-
const redacted =
|
|
29
|
+
const redacted = redactGuardText(text);
|
|
17
30
|
if (redacted === text) return null;
|
|
18
31
|
const before = new Set(text.match(/\[redacted:[^\]]+\]/g) ?? []);
|
|
19
32
|
const found = [...new Set((redacted.match(/\[redacted:[^\]]+\]/g) ?? []).filter((marker) => !before.has(marker)))]
|
package/board/dispatch.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
|
32
32
|
let fleetRoot = null;
|
|
33
33
|
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
34
34
|
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
35
|
+
import { ACTIVE_RUN_IDS, latestEvent, readRunPages } from './queries.mjs';
|
|
35
36
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
36
37
|
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
37
38
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
@@ -50,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
|
50
51
|
import { basename, dirname, join } from 'node:path';
|
|
51
52
|
import { createHash, randomUUID } from 'node:crypto';
|
|
52
53
|
import { fileURLToPath } from 'node:url';
|
|
53
|
-
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
54
|
+
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
54
55
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
55
56
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
56
57
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
@@ -1398,8 +1399,7 @@ function recordInputClose(board, row, session, reading) {
|
|
|
1398
1399
|
/** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
|
|
1399
1400
|
const closesUnderway = new Map();
|
|
1400
1401
|
export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
1401
|
-
const rows =
|
|
1402
|
-
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
1402
|
+
const rows = await readRunPages(board.db, 'id, task_id, profile, ended_at, metadata', { task: taskId, where: 'metadata IS NOT NULL' });
|
|
1403
1403
|
// Repair pre-transfer history once: only identities with an older unended reference need a write.
|
|
1404
1404
|
const latest = new Map(), transfers = new Map();
|
|
1405
1405
|
for (const row of [...rows].sort((a,b) => b.id-a.id)) {
|
|
@@ -1413,10 +1413,15 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1413
1413
|
}
|
|
1414
1414
|
}
|
|
1415
1415
|
if (transfers.size) tx(board, (db) => {
|
|
1416
|
-
for (const row of transfers.values())
|
|
1416
|
+
for (const row of transfers.values()) {
|
|
1417
|
+
// The paged scan yielded. Transfer only from the successor's current retained identity.
|
|
1418
|
+
const current = db.prepare('SELECT task_id,metadata FROM task_runs WHERE id=?').get(row.id);
|
|
1419
|
+
if (current) retireSupersededRuns(db, current.task_id, row.id, json(current.metadata, {})?.supercode);
|
|
1420
|
+
}
|
|
1417
1421
|
});
|
|
1418
1422
|
const panes = new Map();
|
|
1419
1423
|
for (const row of rows) {
|
|
1424
|
+
await yieldToOwner();
|
|
1420
1425
|
let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1421
1426
|
if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
|
|
1422
1427
|
// A keyed launch is reconciled by its machine's own daemon (D137), never by a door the close waits on. A session
|
|
@@ -1474,12 +1479,12 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1474
1479
|
const observed = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1475
1480
|
if (observed?.pane !== session.pane) continue;
|
|
1476
1481
|
if (observed.present === true && observed.yours === true) {
|
|
1477
|
-
if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.
|
|
1482
|
+
if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
|
|
1478
1483
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); })) continue;
|
|
1479
1484
|
finalFailure = false;
|
|
1480
1485
|
} else if (observed.present !== false) continue;
|
|
1481
1486
|
}
|
|
1482
|
-
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.
|
|
1487
|
+
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
|
|
1483
1488
|
// Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
|
|
1484
1489
|
// same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
|
|
1485
1490
|
// only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
|
|
@@ -1531,7 +1536,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1531
1536
|
const observed = panes.get(machine);
|
|
1532
1537
|
if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
|
|
1533
1538
|
if (observed.live.has(session.pane)) {
|
|
1534
|
-
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.
|
|
1539
|
+
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
|
|
1535
1540
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
1536
1541
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
1537
1542
|
// An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
|
|
@@ -1583,7 +1588,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1583
1588
|
// Removing only the card's own left every subtask notifying a closed session, each attempt a failed delivery,
|
|
1584
1589
|
// every round. A later run that resumes it by id subscribes it again at its launch (and each round while it is
|
|
1585
1590
|
// the card's current run).
|
|
1586
|
-
const subscribed = db.prepare(
|
|
1591
|
+
const subscribed = db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all()
|
|
1587
1592
|
.some((r) => addressOf(json(r.metadata, {})?.supercode) === address && r.id !== row.id);
|
|
1588
1593
|
const unsubscribed = subscribed ? 0 : db.prepare("DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id = ? AND (task_id = ? OR json_extract(delivery_metadata, '$.role') = 'worker')").run(address, row.task_id).changes;
|
|
1589
1594
|
event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine, unsubscribed,
|
|
@@ -1722,7 +1727,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1722
1727
|
const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
|
|
1723
1728
|
if (session.held_notice?.key === key) return;
|
|
1724
1729
|
// every run a card works under: an ended run can still be its card's current one
|
|
1725
|
-
const told = db.prepare(
|
|
1730
|
+
const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
|
|
1726
1731
|
setRunSession(db, runRow.id, { held_notice: { key, at: observedAt } });
|
|
1727
1732
|
if (told) return;
|
|
1728
1733
|
const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null });
|
|
@@ -1752,7 +1757,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1752
1757
|
if (reading.quiet_seconds < settings.idleQuiet) { retain('turn_unknown'); return; }
|
|
1753
1758
|
if (session.unknown_notice) return;
|
|
1754
1759
|
const key = [address, live?.turn_since_ms ?? 'unknown'].join('|');
|
|
1755
|
-
const told = db.prepare(
|
|
1760
|
+
const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
|
|
1756
1761
|
.some(r => json(r.metadata, {})?.supercode?.unknown_notice?.key === key);
|
|
1757
1762
|
setRunSession(db, runRow.id, { unknown_notice: { key, at: observedAt, since } });
|
|
1758
1763
|
const observation = retain('turn_unknown_reported', { waiting_for: live?.waiting_for ?? null, turn_source: live?.turn_source ?? null, since_ms: live?.turn_since_ms ?? null });
|
|
@@ -2115,9 +2120,12 @@ async function readRunUsage(root,org,row,endedAt){
|
|
|
2115
2120
|
async function synchronizeAgents(board, report) {
|
|
2116
2121
|
// A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
|
|
2117
2122
|
// A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
|
|
2118
|
-
const agentRuns=[...new Map(
|
|
2123
|
+
const agentRuns=[...new Map((await readRunPages(board.db, 'id,task_id,metadata,ended_at', { where: "json_extract(metadata,'$.supercode.agent_id') IS NOT NULL" }))
|
|
2119
2124
|
.map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
|
|
2120
|
-
for(const
|
|
2125
|
+
for(const scanned of agentRuns){
|
|
2126
|
+
await yieldToOwner();
|
|
2127
|
+
const row=view(board,db=>db.prepare('SELECT id,task_id,metadata,ended_at FROM task_runs WHERE id=?').get(scanned.id));
|
|
2128
|
+
if(!row)continue;
|
|
2121
2129
|
const session=json(row.metadata,{})?.supercode;
|
|
2122
2130
|
if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
|
|
2123
2131
|
const ref=`${session.harness}:${session.session_id}`;
|
|
@@ -2444,7 +2452,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2444
2452
|
} else if (prev.resumable !== false) {
|
|
2445
2453
|
const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
|
|
2446
2454
|
const released = view(board,db=>{
|
|
2447
|
-
const last = db.
|
|
2455
|
+
const last = latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'restart_authorization_consumed'], 'id,kind');
|
|
2448
2456
|
return last?.kind === 'dispatch_released';
|
|
2449
2457
|
});
|
|
2450
2458
|
// Only a session whose run ended in a monitored incident waits for its manager or creator; one a workflow
|
|
@@ -2956,8 +2964,11 @@ async function fileMail(board, to, body, key, { ask = false, wake = true, subjec
|
|
|
2956
2964
|
}
|
|
2957
2965
|
|
|
2958
2966
|
/** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
|
|
2959
|
-
export function fileFleetNotice(root, to, body, key) {
|
|
2960
|
-
|
|
2967
|
+
export async function fileFleetNotice(root, to, body, key) {
|
|
2968
|
+
const board = openBoard(root, 'default');
|
|
2969
|
+
board.label = boardLabel(root, 'default');
|
|
2970
|
+
try { return await fileMail(board, to, body, key); }
|
|
2971
|
+
finally { board.close(); }
|
|
2961
2972
|
}
|
|
2962
2973
|
|
|
2963
2974
|
/**
|
|
@@ -3102,9 +3113,13 @@ function noticeWait(board, sub, report, reason = null) {
|
|
|
3102
3113
|
* the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
|
|
3103
3114
|
* holds it again.
|
|
3104
3115
|
*/
|
|
3105
|
-
function sessionRecord(db) {
|
|
3116
|
+
function sessionRecord(db, address) {
|
|
3106
3117
|
const held = new Set(), latest = new Map();
|
|
3107
|
-
|
|
3118
|
+
const named = sessionAt(address);
|
|
3119
|
+
if (!named) return { held, latest };
|
|
3120
|
+
// Both arms seek one native identity; the join's current_run_id has its own index. Never read unrelated sessions.
|
|
3121
|
+
for (const r of db.prepare(`SELECT r.id,r.task_id,r.ended_at,r.metadata,t.id AS current FROM task_runs r
|
|
3122
|
+
LEFT JOIN tasks t ON t.current_run_id=r.id WHERE r.id IN (${NOTICE_RUN_IDS}) ORDER BY r.id`).all(named.session_id, address)) {
|
|
3108
3123
|
const session = json(r.metadata, {})?.supercode, address = addressOf(session);
|
|
3109
3124
|
if (!address) continue;
|
|
3110
3125
|
if (!r.ended_at || r.current) held.add(address);
|
|
@@ -3152,10 +3167,10 @@ async function endWorkerSubscriptions(board, fleet, report) {
|
|
|
3152
3167
|
if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
|
|
3153
3168
|
}
|
|
3154
3169
|
tx(board, (db) => {
|
|
3155
|
-
const { held, latest } = sessionRecord(db);
|
|
3156
3170
|
const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
|
|
3157
3171
|
const ended = new Map();
|
|
3158
3172
|
for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
|
|
3173
|
+
const { held, latest } = sessionRecord(db, row.chat_id);
|
|
3159
3174
|
const address = row.chat_id, record = latest.get(address);
|
|
3160
3175
|
// a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
|
|
3161
3176
|
if (!record || record.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
|
|
@@ -3185,7 +3200,6 @@ async function endWorkerSubscriptions(board, fleet, report) {
|
|
|
3185
3200
|
*/
|
|
3186
3201
|
async function notify(board, fleet, report, entry) {
|
|
3187
3202
|
await endWorkerSubscriptions(board, fleet, report);
|
|
3188
|
-
const record = view(board, (db) => sessionRecord(db));
|
|
3189
3203
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
|
|
3190
3204
|
await yieldToOwner();
|
|
3191
3205
|
if (stopping) break;
|
|
@@ -3198,6 +3212,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3198
3212
|
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
|
|
3199
3213
|
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
3200
3214
|
if (!events.length) { noticeWait(board, sub, report); continue; }
|
|
3215
|
+
const record = view(board, db => sessionRecord(db, sub.chat_id));
|
|
3201
3216
|
// Events name the acting native session; subscriptions may name its agent.
|
|
3202
3217
|
// Use the daemon's retained binding, including former mains, for both
|
|
3203
3218
|
// coalescing and delivery. An unread binding proves no additional alias.
|
|
@@ -3636,6 +3651,9 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3636
3651
|
report.queued.push([id, `review waits: ${inTurn.why}`]);
|
|
3637
3652
|
continue;
|
|
3638
3653
|
}
|
|
3654
|
+
// A stable local refusal (wrong HEAD or uncommitted work) must not spend a full fresh REST scan every
|
|
3655
|
+
// round. Advisory discovery is sufficient to refuse, never to authorize; the scan below still owns that.
|
|
3656
|
+
if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
|
|
3639
3657
|
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3640
3658
|
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3641
3659
|
const observed = await withFreshReviewReads(async () => {
|
package/board/engine.mjs
CHANGED
|
@@ -468,8 +468,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
468
468
|
// Authorization and the comment row share one transaction: a refusal stores nothing.
|
|
469
469
|
if (name === 'comment') r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
|
|
470
470
|
// the cards that wait on it, and its subtasks, see it change
|
|
471
|
-
|
|
472
|
-
|
|
471
|
+
// Card blockers are mirrored to task_links by relation writes and document imports. Its parent key is the reverse
|
|
472
|
+
// lookup; reading every card's blocked_by JSON on each public verb only rediscovers the same children.
|
|
473
|
+
const around = [...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
|
|
473
474
|
for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
|
|
474
475
|
return r;
|
|
475
476
|
});
|
|
@@ -532,7 +533,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
532
533
|
// it keeps trying at the capped wait, and at EFFECT_ATTEMPTS failed attempts its card records `effect_stalled` and the
|
|
533
534
|
// managers are mailed once, so a passed card whose merge keeps failing is seen, and still heals when the cause clears.
|
|
534
535
|
export const EFFECT_ATTEMPTS = 6;
|
|
535
|
-
const OWNED = new Set(['merge_reviewed', 'cleanup_workspace']);
|
|
536
|
+
const OWNED = new Set(['merge_reviewed', 'cleanup_workspace', 'rename_card_agents']);
|
|
536
537
|
const effectWait = (attempts) => Math.min(1800, 30 * 2 ** Math.max(0, attempts - 1));
|
|
537
538
|
// A delivery pass claims a row by compare-and-set on its next_at (set ahead only while the row is due) before trying it,
|
|
538
539
|
// so two passes (serve's, the hosted loop's, a person's `workflow deliver`, a verb's own) never try one effect at once.
|
|
@@ -620,6 +621,7 @@ async function runEffect(board, e) {
|
|
|
620
621
|
if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
|
|
621
622
|
if (e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id); return true; }
|
|
622
623
|
if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
|
|
624
|
+
if (e.kind === 'rename_card_agents') { const { renameCardAgentsEffect } = await import('./agent-declarations.mjs'); return await renameCardAgentsEffect(board, e); }
|
|
623
625
|
// a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
|
|
624
626
|
if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); await fileBoardMail(board, { undelivered: [] }, e.id); return true; }
|
|
625
627
|
if (e.kind === 'merge_reviewed') { const { mergeReviewed } = await import('./dispatch.mjs'); return await mergeReviewed(board, e) !== false; }
|
package/board/event-stream.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { watch, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { dirname, basename, join, resolve } from 'node:path';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { once } from 'node:events';
|
|
6
7
|
import { listBoards, openBoard, backingPaths } from './store.mjs';
|
|
7
8
|
|
|
8
9
|
const identity = root => createHash('sha256').update(realpathSync(root)).digest('hex');
|
|
@@ -117,14 +118,47 @@ export async function* followBoardPublications({root,after,signal}) {
|
|
|
117
118
|
|
|
118
119
|
export async function streamBoardPublications(options) {
|
|
119
120
|
if(options.once&&options.after)throw new Error('--once requires a fresh snapshot; omit --after');
|
|
121
|
+
const parent=process.ppid, reader=Number(process.env.SUPERCODE_WORKFLOW_READER_PID??parent);
|
|
122
|
+
if(!Number.isSafeInteger(reader)||reader<1||reader>2147483647)throw new Error('Invalid SUPERCODE_WORKFLOW_READER_PID');
|
|
120
123
|
const abort=new AbortController();const stop=()=>abort.abort();
|
|
124
|
+
let outputError=null;
|
|
125
|
+
const failed=error=>{outputError=error;stop();};
|
|
121
126
|
process.once('SIGINT',stop);process.once('SIGTERM',stop);
|
|
127
|
+
process.stdout.on('error',failed);process.stdout.once('close',stop);
|
|
128
|
+
// Quiet streams never meet EPIPE. The connector names the real reader across native/npm/home hand-offs; direct
|
|
129
|
+
// callers use their parent. Unix also detects reparenting (including death before this command finished starting).
|
|
130
|
+
// On Windows ppid does not change, but probing the named reader still detects its exit.
|
|
131
|
+
const checkReader=()=>{
|
|
132
|
+
if(process.platform!=='win32'&&(parent===1||process.ppid!==parent)){stop();return;}
|
|
133
|
+
try{process.kill(reader,0);}catch(error){if(error.code==='ESRCH')stop();}
|
|
134
|
+
};
|
|
135
|
+
const lifetime=setInterval(checkReader,1000);lifetime.unref();checkReader();
|
|
136
|
+
const write=async frame=>{
|
|
137
|
+
if(abort.signal.aborted)return;
|
|
138
|
+
try{
|
|
139
|
+
if(!process.stdout.write(`${JSON.stringify(frame)}\n`))await once(process.stdout,'drain',{signal:abort.signal});
|
|
140
|
+
}catch(error){
|
|
141
|
+
if(error.name==='AbortError'&&abort.signal.aborted)return;
|
|
142
|
+
if(error.code==='EPIPE'){stop();return;}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
let code=0;
|
|
122
147
|
try {
|
|
123
148
|
for await(const frame of followBoardPublications({...options,signal:abort.signal})){
|
|
124
|
-
if(
|
|
149
|
+
if(abort.signal.aborted)break;
|
|
150
|
+
await write(frame);
|
|
151
|
+
if(abort.signal.aborted)break;
|
|
125
152
|
if(options.once&&frame.type==='snapshot_end')break;
|
|
126
153
|
}
|
|
127
|
-
}catch(error){if(error.code==='resync_required'){
|
|
128
|
-
finally{
|
|
129
|
-
|
|
154
|
+
}catch(error){if(error.code==='resync_required'){await write({type:'resync_required',reason:error.message});code=1;}else throw error;}
|
|
155
|
+
finally{
|
|
156
|
+
clearInterval(lifetime);process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);
|
|
157
|
+
// No final flush can drain into a dead or stalled reader. Returning the iterator above closes its board and
|
|
158
|
+
// watchers first; discard pending bytes on cancellation so the public entry can finish too.
|
|
159
|
+
if(abort.signal.aborted)process.stdout.destroy();
|
|
160
|
+
process.stdout.removeListener('error',failed);process.stdout.removeListener('close',stop);
|
|
161
|
+
}
|
|
162
|
+
if(outputError&&outputError.code!=='EPIPE')throw outputError;
|
|
163
|
+
return code;
|
|
130
164
|
}
|
|
@@ -15,9 +15,9 @@ const refused = message => Object.assign(new Error(message), { code: 'fleet_list
|
|
|
15
15
|
/** A retained mail outbox. `file` is the board's existing admitted mailbox filing door, never a transport or
|
|
16
16
|
* provider selected from the observed payload. No runtime database is opened until the home opts into listeners. */
|
|
17
17
|
export class FleetListenerRouter {
|
|
18
|
-
constructor({ root, file, enrich = event => event, retry = () => {}, log = () => {} }) {
|
|
18
|
+
constructor({ root, file, enrich = event => event, work = fn => fn(), retry = () => {}, log = () => {} }) {
|
|
19
19
|
if (typeof file !== 'function') throw refused('fleet listener router needs the admitted mailbox filing door');
|
|
20
|
-
this.root = root; this.file = file; this.enrich = enrich; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
|
|
20
|
+
this.root = root; this.file = file; this.enrich = enrich; this.work = work; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** The workflow's decoded declarations. An invalid replacement refuses without discarding the prior outbox. */
|
|
@@ -37,7 +37,15 @@ export class FleetListenerRouter {
|
|
|
37
37
|
this.suspended = false;
|
|
38
38
|
if (Object.keys(next).length) {
|
|
39
39
|
this.store();
|
|
40
|
-
if (!this.timer) {
|
|
40
|
+
if (!this.timer) {
|
|
41
|
+
this.timer = setInterval(() => {
|
|
42
|
+
void Promise.resolve().then(() => {
|
|
43
|
+
if (!this.closed && !this.suspended) return this.retry();
|
|
44
|
+
}).catch(error => this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`))
|
|
45
|
+
.finally(() => this.wake());
|
|
46
|
+
}, 1000);
|
|
47
|
+
this.timer.unref?.();
|
|
48
|
+
}
|
|
41
49
|
this.wake();
|
|
42
50
|
} else if (this.db) {
|
|
43
51
|
this.db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,1) ON CONFLICT(id) DO UPDATE SET needs_baseline=1').run();
|
|
@@ -212,7 +220,10 @@ export class FleetListenerRouter {
|
|
|
212
220
|
flush() {
|
|
213
221
|
if (this.flushing) return this.flushing;
|
|
214
222
|
if (!this.db || this.closed || this.suspended) return Promise.resolve();
|
|
215
|
-
|
|
223
|
+
// Prepare owner work before replaying retained waits too. Enrichment and enqueue remain synchronous, so the
|
|
224
|
+
// source cursor cannot advance until its complete event body is committed to the outbox.
|
|
225
|
+
this.flushing = Promise.resolve().then(() => this.work(async () => {
|
|
226
|
+
if (this.closed || this.suspended) return;
|
|
216
227
|
// Even a prompt that has since cleared keeps an earlier refused observation. Do not block already queued mail
|
|
217
228
|
// on a full outbox: file it below to make room, then the retained prompt retries at the next wake.
|
|
218
229
|
for (const wait of this.db.prepare('SELECT address,event FROM fleet_listener_waits WHERE routed=0 LIMIT 16').all()) {
|
|
@@ -238,7 +249,7 @@ export class FleetListenerRouter {
|
|
|
238
249
|
this.db.prepare('UPDATE fleet_listener_mail SET attempts=?,next_at=?,error=? WHERE id=?').run(attempts, Date.now() + delay, String(error.message).slice(0, 500), row.id);
|
|
239
250
|
}
|
|
240
251
|
}
|
|
241
|
-
}).finally(() => { this.flushing = null; });
|
|
252
|
+
})).finally(() => { this.flushing = null; });
|
|
242
253
|
return this.flushing;
|
|
243
254
|
}
|
|
244
255
|
|
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);
|
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) {
|
|
@@ -469,7 +470,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
469
470
|
get latest_review() { return latestReview(db, task.id) ?? {}; },
|
|
470
471
|
get review_candidate() { return reviewCandidate(board, task.id) ?? {}; },
|
|
471
472
|
ci: completionEvidence(board, task.id),
|
|
472
|
-
get dispatch_hold() { return
|
|
473
|
+
get dispatch_hold() { return latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'kind')?.kind === 'dispatch_held'; },
|
|
473
474
|
status,
|
|
474
475
|
blocked_by: blocks,
|
|
475
476
|
paused: json(state.paused, null),
|
|
@@ -1140,6 +1141,13 @@ const ACTIONS = {
|
|
|
1140
1141
|
const task = cardOf(ctx.db, ctx.id);
|
|
1141
1142
|
const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
|
|
1142
1143
|
run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
|
|
1144
|
+
if (readable !== task.readable_id) {
|
|
1145
|
+
// Keep the obligation with the edit: a stopped dispatcher or interrupted caller must not lose the rename.
|
|
1146
|
+
// Delivery reads the current name, so an older edit can never restore an obsolete name.
|
|
1147
|
+
const effect = { kind: 'rename_card_agents', id: ctx.id };
|
|
1148
|
+
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
1149
|
+
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
1150
|
+
}
|
|
1143
1151
|
}
|
|
1144
1152
|
if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
|
|
1145
1153
|
if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
|
|
@@ -1231,11 +1239,12 @@ const ACTIONS = {
|
|
|
1231
1239
|
/** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
|
|
1232
1240
|
export function retireSupersededRuns(db, id, runId, session) {
|
|
1233
1241
|
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
|
-
|
|
1242
|
+
for (const old of all(db, `SELECT id,task_id,metadata FROM task_runs WHERE id IN (
|
|
1243
|
+
SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1244
|
+
AND json_extract(metadata, '$.supercode.session_id')=?
|
|
1245
|
+
UNION SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1246
|
+
AND json_extract(metadata, '$.supercode.pane')=?)`,
|
|
1247
|
+
runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
|
|
1239
1248
|
const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
|
|
1240
1249
|
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
1250
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.211",
|
|
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": {
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@volter/supercode-teams": "^0.3.
|
|
35
|
+
"@volter/supercode-teams": "^0.3.346",
|
|
36
36
|
"@volter/supercode-terminal": "^0.3.10",
|
|
37
37
|
"@volter/supercode-harness-sdk": "^0.3.104",
|
|
38
|
-
"@volter/teams": "0.2.
|
|
38
|
+
"@volter/teams": "0.2.99",
|
|
39
39
|
"@volter/ztrack": "^2.4.23",
|
|
40
40
|
"yaml": "^2.9.0"
|
|
41
41
|
},
|
|
@@ -61,5 +61,6 @@
|
|
|
61
61
|
"board",
|
|
62
62
|
"apply",
|
|
63
63
|
"README.md"
|
|
64
|
-
]
|
|
64
|
+
],
|
|
65
|
+
"gitHead": "38f4a38369c31949d4d81fd3520a6aa678582ead"
|
|
65
66
|
}
|