@volter/supercode-orchestrator 0.5.211 → 0.5.212
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/orchestrator.mjs +5 -0
- package/board/attachment-write.mjs +68 -0
- package/board/cli.mjs +12 -1
- package/board/dispatch.mjs +86 -22
- package/board/engine.mjs +19 -10
- package/board/store.mjs +2 -2
- package/board/workflow.mjs +26 -9
- package/board/workspace.mjs +21 -10
- package/board-input.d.ts +2 -0
- package/board-input.mjs +33 -1
- package/hermes-cli.mjs +7 -19
- package/index.d.ts +1 -1
- package/loop.mjs +11 -1
- package/mcp/mount.mjs +22 -14
- package/orchestration.mjs +29 -2
- package/package.json +3 -4
package/bin/orchestrator.mjs
CHANGED
|
@@ -53,6 +53,11 @@ if (process.argv[2] === 'workflow') {
|
|
|
53
53
|
const {disconnectWorkplace}=await import('../connection.mjs');
|
|
54
54
|
try{const {values}=parseArgs({args:process.argv.slice(3),options:{root:{type:'string'},connection:{type:'string'}}});process.stdout.write(JSON.stringify(await disconnectWorkplace(values),null,2)+'\n');}
|
|
55
55
|
catch(error){process.stderr.write(`disconnect: ${error.message}\n`);process.exitCode=1;}
|
|
56
|
+
} else if (process.argv[2] === 'mcp-server') {
|
|
57
|
+
// a session's orchestrator tools on stdio, bound by its environment (mcp/mount.mjs mounts this through the stable
|
|
58
|
+
// supercode)
|
|
59
|
+
const {serveStdio,bindingFromEnv}=await import('../mcp/server.mjs');
|
|
60
|
+
serveStdio(bindingFromEnv());
|
|
56
61
|
} else if (process.argv[2] === 'agent-channel') {
|
|
57
62
|
await import('./agent-channel.mjs');
|
|
58
63
|
} else {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Upload staging belongs to the board, beneath the authorized card, and to one caller.
|
|
2
|
+
// No attachment record exists until all bytes and their digest have been verified.
|
|
3
|
+
import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, rmSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const CHUNK = 1024 * 1024;
|
|
8
|
+
const safeName = name => basename(String(name)).replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^\.+$/, '') || 'attachment';
|
|
9
|
+
|
|
10
|
+
export function receiveAttachment({ directory, upload, caller, input, add }) {
|
|
11
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(upload) || !caller)
|
|
12
|
+
throw new Error('attachment upload needs its identifier and an admitted caller');
|
|
13
|
+
let body;
|
|
14
|
+
try { body = JSON.parse(input); } catch { throw new Error('attachment upload needs JSON input'); }
|
|
15
|
+
const staging = join(directory, '.uploads', upload), metadata = join(staging, 'upload.json'), path = join(staging, 'bytes');
|
|
16
|
+
if (body?.action === 'begin') {
|
|
17
|
+
if (!Number.isSafeInteger(body.size) || body.size < 0 || typeof body.name !== 'string') throw new Error('invalid attachment extent or name');
|
|
18
|
+
mkdirSync(join(directory, '.uploads'), { recursive: true });
|
|
19
|
+
mkdirSync(staging, { mode: 0o700 });
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(path, '', { flag: 'wx', mode: 0o600 });
|
|
22
|
+
writeFileSync(metadata, JSON.stringify({ caller, size: body.size, name: safeName(body.name) }), { flag: 'wx', mode: 0o600 });
|
|
23
|
+
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
24
|
+
return { ok: true, upload, received: 0 };
|
|
25
|
+
}
|
|
26
|
+
const held = JSON.parse(readFileSync(metadata, 'utf8'));
|
|
27
|
+
if (held.caller !== caller) throw new Error('attachment upload belongs to another caller');
|
|
28
|
+
if (body?.action === 'abort') { rmSync(staging, { recursive: true }); return { ok: true, upload, aborted: true }; }
|
|
29
|
+
if (body?.action === 'append') {
|
|
30
|
+
const bytes = typeof body.bytes === 'string' && body.bytes.length <= Math.ceil(CHUNK / 3) * 4 ? Buffer.from(body.bytes, 'base64') : null;
|
|
31
|
+
if (!bytes?.length || bytes.length > CHUNK || bytes.toString('base64') !== body.bytes
|
|
32
|
+
|| !Number.isSafeInteger(body.offset) || body.offset < 0 || body.offset + bytes.length > held.size)
|
|
33
|
+
throw new Error('invalid attachment chunk');
|
|
34
|
+
const file = openSync(path, 'r+');
|
|
35
|
+
try {
|
|
36
|
+
if (fstatSync(file).size !== body.offset) throw new Error('attachment chunk is not at its next offset');
|
|
37
|
+
for (let written = 0; written < bytes.length;) {
|
|
38
|
+
const count = writeSync(file, bytes, written, bytes.length - written, body.offset + written);
|
|
39
|
+
if (count <= 0) throw new Error('attachment chunk write stopped');
|
|
40
|
+
written += count;
|
|
41
|
+
}
|
|
42
|
+
} finally { closeSync(file); }
|
|
43
|
+
return { ok: true, upload, received: body.offset + bytes.length };
|
|
44
|
+
}
|
|
45
|
+
if (body?.action !== 'commit' || !/^[a-f0-9]{64}$/.test(body.sha256)) throw new Error('invalid attachment upload action or digest');
|
|
46
|
+
const file = openSync(path, 'r'), hash = createHash('sha256'), buffer = Buffer.alloc(CHUNK);
|
|
47
|
+
try {
|
|
48
|
+
if (fstatSync(file).size !== held.size) throw new Error('attachment upload is incomplete');
|
|
49
|
+
for (let offset = 0; offset < held.size;) {
|
|
50
|
+
const count = readSync(file, buffer, 0, Math.min(CHUNK, held.size - offset), offset);
|
|
51
|
+
if (count <= 0) throw new Error('attachment upload ended before its extent');
|
|
52
|
+
hash.update(buffer.subarray(0, count)); offset += count;
|
|
53
|
+
}
|
|
54
|
+
if (hash.digest('hex') !== body.sha256) throw new Error('attachment upload digest does not match the source');
|
|
55
|
+
} finally { closeSync(file); }
|
|
56
|
+
let target;
|
|
57
|
+
for (let n = 0;; n++) {
|
|
58
|
+
target = join(directory, n ? `${n}-${held.name}` : held.name);
|
|
59
|
+
try { linkSync(path, target); break; } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
60
|
+
}
|
|
61
|
+
let id;
|
|
62
|
+
try { id = add({ path: target, size: held.size }, held.name); }
|
|
63
|
+
catch (error) { unlinkSync(target); throw error; }
|
|
64
|
+
// The committed record is authoritative; failed staging cleanup must not turn success into an ambiguous retry.
|
|
65
|
+
let cleanup = null;
|
|
66
|
+
try { rmSync(staging, { recursive: true }); } catch { cleanup = staging; }
|
|
67
|
+
return { ok: true, id, path: target, size: held.size, sha256: body.sha256, name: held.name, ...(cleanup ? { cleanup } : {}) };
|
|
68
|
+
}
|
package/board/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { detachSession } from './custody.mjs';
|
|
|
15
15
|
import { taskStatusOf } from './published.mjs';
|
|
16
16
|
import { executionOf } from './session-work.mjs';
|
|
17
17
|
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
18
|
+
import { receiveAttachment } from './attachment-write.mjs';
|
|
18
19
|
import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
|
|
19
20
|
import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
|
|
20
21
|
import { openOwnerDoor } from './owner-door.mjs';
|
|
@@ -112,13 +113,14 @@ The board's verbs, with their flags:
|
|
|
112
113
|
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] [--source owner/repo:branch]... attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
113
114
|
attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
|
|
114
115
|
complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
|
|
116
|
+
finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
|
|
115
117
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
116
118
|
pause <id> --reason <why> resume <id> cancel <id> --reason <why>
|
|
117
119
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
118
120
|
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
119
121
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
120
122
|
specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
121
|
-
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
123
|
+
decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
|
|
122
124
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
123
125
|
github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
|
|
124
126
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
@@ -833,6 +835,15 @@ export async function runVerb(argv) {
|
|
|
833
835
|
}
|
|
834
836
|
case 'attach': {
|
|
835
837
|
need(2, 'a card id and a file');
|
|
838
|
+
if (args.upload !== undefined) {
|
|
839
|
+
if (rest[1] !== '-') throw new Error('attachment upload reads its carried stdin');
|
|
840
|
+
withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
|
|
841
|
+
const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
|
|
842
|
+
const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
|
|
843
|
+
add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
|
|
844
|
+
say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
|
|
845
|
+
return 0;
|
|
846
|
+
}
|
|
836
847
|
const source = callerPath(rest[1]);
|
|
837
848
|
if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
|
|
838
849
|
const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
|
package/board/dispatch.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { readMachineFleet } from './machine-fleet.mjs';
|
|
|
27
27
|
import { homeFleetMachines } from './fleet-targets.mjs';
|
|
28
28
|
import { delegatedWork, idleInterval } from './session-work.mjs';
|
|
29
29
|
import { localSessionDelegations } from '../agents.mjs';
|
|
30
|
-
import { creatorOf, sessionProgress } from './workflow.mjs';
|
|
30
|
+
import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
|
|
31
31
|
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';
|
|
@@ -51,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
|
51
51
|
import { basename, dirname, join } from 'node:path';
|
|
52
52
|
import { createHash, randomUUID } from 'node:crypto';
|
|
53
53
|
import { fileURLToPath } from 'node:url';
|
|
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
|
+
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
55
55
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
56
56
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
57
57
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
@@ -830,7 +830,14 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
830
830
|
const out = [];
|
|
831
831
|
for (const slug of slugs) {
|
|
832
832
|
try {
|
|
833
|
-
|
|
833
|
+
// Capacity spans awaits. It needs its own committed snapshot, not the shared writer's total_changes counter:
|
|
834
|
+
// unrelated owner calls and delivery writes used to invalidate all three attempts at this read.
|
|
835
|
+
let b;
|
|
836
|
+
try { b = openBoardSnapshot(root, slug); }
|
|
837
|
+
catch (error) {
|
|
838
|
+
if (error.code !== 'board_read_by_owner') throw error;
|
|
839
|
+
b = openBoard(root, slug); // files/non-WAL backings retain their guarded optimistic read
|
|
840
|
+
}
|
|
834
841
|
try {
|
|
835
842
|
b.slug ??= slug; b.label ??= boardLabel(root, slug);
|
|
836
843
|
// each run's session read before it is counted live or not (unknown stays unknown)
|
|
@@ -840,7 +847,7 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
840
847
|
runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
|
|
841
848
|
}
|
|
842
849
|
finally { b.close(); }
|
|
843
|
-
} catch (error) {
|
|
850
|
+
} catch (error) { error.capacityUnread = true; throw error; }
|
|
844
851
|
}
|
|
845
852
|
out.defaults = harnessDefaults(out);
|
|
846
853
|
return out;
|
|
@@ -927,6 +934,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
927
934
|
if (task.subtask_of || task.current_run_id) continue;
|
|
928
935
|
const state = scopeFor(task).card;
|
|
929
936
|
if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
|
|
937
|
+
if (state.status === 'running' && completedWork(board.db, task.id)) {
|
|
938
|
+
waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
|
|
939
|
+
}
|
|
930
940
|
if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
|
|
931
941
|
if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
|
|
932
942
|
if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
|
|
@@ -991,7 +1001,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
991
1001
|
const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
|
|
992
1002
|
if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
|
|
993
1003
|
assertWorkspaceOwner(db, task, checked);
|
|
994
|
-
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1004
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
|
|
995
1005
|
if (!task.assignee && plan.profile) {
|
|
996
1006
|
db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
|
|
997
1007
|
event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
|
|
@@ -1076,7 +1086,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1076
1086
|
const checked = await checkWorkspace(board, task);
|
|
1077
1087
|
if (checked) runTx(board, id, runId, (db) => {
|
|
1078
1088
|
assertWorkspaceOwner(db, task, checked);
|
|
1079
|
-
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1089
|
+
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1080
1090
|
});
|
|
1081
1091
|
const status = view(board, (db) => statusOf(db, task));
|
|
1082
1092
|
const spec = runSpec(entry, status, task);
|
|
@@ -1209,7 +1219,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
1209
1219
|
const moved = tx(board, (db) => {
|
|
1210
1220
|
if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
|
|
1211
1221
|
assertWorkspaceOwner(db, before, checked);
|
|
1212
|
-
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1222
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1213
1223
|
if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
|
|
1214
1224
|
event(db, id, 'dispatch_released', { reason: 'explicit start' });
|
|
1215
1225
|
requestSessionClose(db,id);
|
|
@@ -1450,7 +1460,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1450
1460
|
continue;
|
|
1451
1461
|
}
|
|
1452
1462
|
}
|
|
1453
|
-
if (session?.monitor_held && !session.close_requested) continue; // only an explicit close action ends this process
|
|
1463
|
+
if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
|
|
1454
1464
|
if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
|
|
1455
1465
|
if (caller && session.session_id === caller) continue; // let the command that ended its own run return
|
|
1456
1466
|
// The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
|
|
@@ -1617,7 +1627,18 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1617
1627
|
return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
|
|
1618
1628
|
};
|
|
1619
1629
|
const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
|
|
1620
|
-
const
|
|
1630
|
+
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
1631
|
+
const edges = localSessionDelegations(board.root, localMachine());
|
|
1632
|
+
const reached = new Set(running.map(({ id }) => addressOf(currentRun(board.db, id)?.metadata?.supercode)).filter(Boolean));
|
|
1633
|
+
const delegations = [];
|
|
1634
|
+
const pending = [...reached];
|
|
1635
|
+
const byStarter = new Map();
|
|
1636
|
+
for (const edge of edges) { const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children); }
|
|
1637
|
+
while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
|
|
1638
|
+
delegations.push(edge);
|
|
1639
|
+
if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
|
|
1640
|
+
}
|
|
1641
|
+
report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
|
|
1621
1642
|
const delegatedReadings = [...fleet.sessions];
|
|
1622
1643
|
// Forks and concurrent sessions need not have a pane. Read only the exact
|
|
1623
1644
|
// native identities this home's starter receipts name, before any write.
|
|
@@ -1633,7 +1654,6 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1633
1654
|
if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
|
|
1634
1655
|
} catch { /* an unread child is never called working */ }
|
|
1635
1656
|
}
|
|
1636
|
-
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
1637
1657
|
// every running card a session is on: a session waiting on its user is told once, naming them all
|
|
1638
1658
|
const cardsOf = new Map();
|
|
1639
1659
|
for (const { id } of running) {
|
|
@@ -2284,6 +2304,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2284
2304
|
const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
|
|
2285
2305
|
.map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
|
|
2286
2306
|
.find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at
|
|
2307
|
+
// Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
|
|
2308
|
+
// checks below; an explicit close or another unresolved identity still holds the slot.
|
|
2309
|
+
&& !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
|
|
2310
|
+
&& r.session.session_id === last.session_id && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
|
|
2287
2311
|
&& !(closeRefused(r.session) && last?.session_id && r.session.session_id === last.session_id));
|
|
2288
2312
|
if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
|
|
2289
2313
|
let prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
@@ -2431,13 +2455,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2431
2455
|
if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
|
|
2432
2456
|
if (next === 'keep') {
|
|
2433
2457
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
2434
|
-
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, ...kept } = prev;
|
|
2458
|
+
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, ...kept } = prev;
|
|
2435
2459
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
2436
2460
|
// a kept session is told what this run is for (its card came back to it, with what happened since), only while
|
|
2437
2461
|
// this run is still the card's current one (its pane and native reads were awaited)
|
|
2438
2462
|
tx(board, (db) => {
|
|
2439
2463
|
if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its session was read; nothing was told to it`), { launchSuperseded: true });
|
|
2440
2464
|
setRunSession(db, runId, { ...session, ...meta });
|
|
2465
|
+
transferRetainedSession(db, task.id, runId, session);
|
|
2441
2466
|
// an earlier run's close of this pane is this run's now: the pane is the kept session's, not a close to retry
|
|
2442
2467
|
if (prev.pane) for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
|
|
2443
2468
|
const s = json(r.metadata, {})?.supercode;
|
|
@@ -2506,6 +2531,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2506
2531
|
const saved = json(row?.metadata, {})?.supercode;
|
|
2507
2532
|
if (saved?.address === session.address && !saved.launch_pending) return true;
|
|
2508
2533
|
setRunSession(db, runId, session);
|
|
2534
|
+
transferRetainedSession(db, task.id, runId, session);
|
|
2509
2535
|
event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
|
|
2510
2536
|
// the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
|
|
2511
2537
|
// leaves the implementer's, and a subtask's arc session's, subscription alone)
|
|
@@ -2520,6 +2546,19 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2520
2546
|
return session;
|
|
2521
2547
|
}
|
|
2522
2548
|
|
|
2549
|
+
/** A continued role identity now belongs to its new attempt. Preserve the completed receipt, and keep an old
|
|
2550
|
+
* retained reference from closing the same native process/pane later. Transfer only after a successful bind. */
|
|
2551
|
+
function transferRetainedSession(db, id, runId, session) {
|
|
2552
|
+
if (!session?.session_id) return;
|
|
2553
|
+
for (const row of db.prepare('SELECT id,metadata FROM task_runs WHERE task_id=? AND id<? AND ended_at IS NOT NULL').all(id, runId)) {
|
|
2554
|
+
const old = json(row.metadata, {})?.supercode;
|
|
2555
|
+
if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
|
|
2556
|
+
|| old.session_id !== session.session_id || old.machine !== session.machine || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
|
|
2557
|
+
setRunSession(db, row.id, { close_transferred_to: runId });
|
|
2558
|
+
event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2523
2562
|
/** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
|
|
2524
2563
|
* owner of the session where it records one in this run's org, else the agent its own run ran as. Null when it is
|
|
2525
2564
|
* this run's agent, or no agent is known for it (a session from before card agents). */
|
|
@@ -2816,8 +2855,10 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2816
2855
|
// agent store another, and every round after would trip on it.
|
|
2817
2856
|
const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
|
|
2818
2857
|
if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
|
|
2819
|
-
//
|
|
2820
|
-
|
|
2858
|
+
// Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
|
|
2859
|
+
// adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
|
|
2860
|
+
const adoptionSlot = beforeSession?.slot ?? runSpec(entry0, view(board, db => statusOf(db, card0)), card0)?.session ?? 'worker';
|
|
2861
|
+
await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
|
|
2821
2862
|
const adopted = tx(board, (db) => {
|
|
2822
2863
|
const task = getTask(db, id);
|
|
2823
2864
|
if (!task) throw new Error(`no card ${id}`);
|
|
@@ -2826,7 +2867,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2826
2867
|
if (changed) throw changed;
|
|
2827
2868
|
if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
|
|
2828
2869
|
if (task.machine && normalMachine(task.machine) !== host) throw new Error(`${id} runs on ${task.machine}, and ${found.address} is on ${host}; move the card first`);
|
|
2829
|
-
const was = lastSession(db, id);
|
|
2870
|
+
const was = lastSession(db, id, adoptionSlot);
|
|
2830
2871
|
// a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
|
|
2831
2872
|
// left running, and is read)
|
|
2832
2873
|
if (was && was.session_id !== sessionId && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
|
|
@@ -3526,14 +3567,37 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
3526
3567
|
return out;
|
|
3527
3568
|
}
|
|
3528
3569
|
|
|
3570
|
+
/** An explicit completion of the latest attempt, after any previous review or reopening. A reclaimed or failed
|
|
3571
|
+
* attempt is not a completed disposition, and a new attempt or returned verdict invalidates the old receipt. */
|
|
3572
|
+
function completedWork(db, id) {
|
|
3573
|
+
const last = db.prepare(`SELECT id,kind,run_id FROM task_events WHERE task_id=?
|
|
3574
|
+
AND kind IN ('work_completed','review_requested','changes_requested','review_pass','review_voided','reopened')
|
|
3575
|
+
ORDER BY id DESC LIMIT 1`).get(id);
|
|
3576
|
+
if (last?.kind !== 'work_completed') return null;
|
|
3577
|
+
const attempt = db.prepare('SELECT id,outcome,ended_at FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
|
|
3578
|
+
return attempt?.id === last.run_id && attempt.outcome === 'completed' && attempt.ended_at != null ? last : null;
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
/** Finish ends the attempt, not its caller's native turn. Keep observing that retained attempt until the board
|
|
3582
|
+
* requests review; the receipt's lifecycle/latest-attempt fence prevents an older session holding later work. */
|
|
3583
|
+
function reviewTurnRun(db, id) {
|
|
3584
|
+
const active = currentRun(db, id);
|
|
3585
|
+
if (active) return active;
|
|
3586
|
+
const completed = completedWork(db, id);
|
|
3587
|
+
if (!completed || detachedCard(db, id)) return null;
|
|
3588
|
+
const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(completed.run_id);
|
|
3589
|
+
const metadata = json(row?.metadata, {}) ?? {}, session = metadata.supercode;
|
|
3590
|
+
return row && session?.retain_after_run && !session.close_transferred_to && !detachedSession(db, session) ? { ...row, metadata } : null;
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3529
3593
|
/** Whether an arc with no open PR may be one that changed no code and is due its review: it has done work, by filing
|
|
3530
|
-
* tasks or
|
|
3594
|
+
* tasks, an explicit completed-work receipt, or a completed run with an Acceptance outcome ticked, and no
|
|
3531
3595
|
* review of it is pending or passed (after changes are requested, closing the tasks that own the findings makes it
|
|
3532
3596
|
* due again). */
|
|
3533
3597
|
function noCodeReviewDue(board, id) {
|
|
3534
3598
|
return view(board, (db) => {
|
|
3535
3599
|
const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
|
|
3536
|
-
if (!filed && !(db.prepare(
|
|
3600
|
+
if (!filed && !completedWork(db, id) && !(db.prepare("SELECT 1 FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at IS NOT NULL LIMIT 1").get(id) &&
|
|
3537
3601
|
outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
|
|
3538
3602
|
const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
|
|
3539
3603
|
return !last || ['changes_requested','review_voided'].includes(last.kind);
|
|
@@ -3549,8 +3613,8 @@ function passedReview(board, id) {
|
|
|
3549
3613
|
function outcomeTicked(body) {
|
|
3550
3614
|
let acceptance = false;
|
|
3551
3615
|
return String(body ?? '').split('\n').some((line) => {
|
|
3552
|
-
const heading =
|
|
3553
|
-
if (heading) acceptance = heading
|
|
3616
|
+
const heading = cardSectionHeading(line);
|
|
3617
|
+
if (heading !== null) acceptance = heading === 'acceptance';
|
|
3554
3618
|
return acceptance && /^\s*[-*] \[[xX]\] \S/.test(line);
|
|
3555
3619
|
});
|
|
3556
3620
|
}
|
|
@@ -3571,7 +3635,7 @@ function outcomeTicked(body) {
|
|
|
3571
3635
|
* running a proof, still holds the review. */
|
|
3572
3636
|
async function turnInFlight(board, id, fleet, heldSince = null) {
|
|
3573
3637
|
if (!fleet?.ok) return null;
|
|
3574
|
-
const session =
|
|
3638
|
+
const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
|
|
3575
3639
|
if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
|
|
3576
3640
|
let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
|
|
3577
3641
|
if (live?.turn == null && session.surface === 'native') try {
|
|
@@ -3643,11 +3707,11 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3643
3707
|
}
|
|
3644
3708
|
// A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
|
|
3645
3709
|
// (`review_held`) so a goal turn can be told to have started after that work was first observed.
|
|
3646
|
-
const heldRun =
|
|
3710
|
+
const heldRun = reviewTurnRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
|
|
3647
3711
|
const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
|
|
3648
3712
|
const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
|
|
3649
3713
|
if (inTurn) {
|
|
3650
|
-
if (!held && !dry && heldRun) tx(board, (db) => { if (
|
|
3714
|
+
if (!held && !dry && heldRun) tx(board, (db) => { if (reviewTurnRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
|
|
3651
3715
|
report.queued.push([id, `review waits: ${inTurn.why}`]);
|
|
3652
3716
|
continue;
|
|
3653
3717
|
}
|
|
@@ -3902,7 +3966,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
3902
3966
|
if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
|
|
3903
3967
|
// what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
|
|
3904
3968
|
queueEffects(board, effects);
|
|
3905
|
-
phase('
|
|
3969
|
+
phase('idle');
|
|
3906
3970
|
ensureSubscriptions(board, entry);
|
|
3907
3971
|
fireHooks(board, settings, report);
|
|
3908
3972
|
if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
package/board/engine.mjs
CHANGED
|
@@ -292,7 +292,7 @@ export function createCard(board, spec) {
|
|
|
292
292
|
const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
|
|
293
293
|
if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
|
|
294
294
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
295
|
-
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.
|
|
295
|
+
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.workspaceRoot ?? null,
|
|
296
296
|
tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
|
|
297
297
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
298
298
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
@@ -740,17 +740,26 @@ export const editResult = (board, id, { result, summary = null, metadata = undef
|
|
|
740
740
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
741
741
|
export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
|
|
742
742
|
|
|
743
|
-
/**
|
|
743
|
+
/** File the named card's tasks only if its workflow accepts decompose; refusal leaves no cards behind. */
|
|
744
744
|
export async function decompose(board, id, children, { author = 'user', roles } = {}) {
|
|
745
745
|
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
746
|
+
const entry = await loadWorkflow(board.root);
|
|
747
|
+
await refreshCardInputs(board, id);
|
|
748
|
+
const result = tx(board, db => {
|
|
749
|
+
const task = must(db, id), sender = roles ?? callerRoles(db, id);
|
|
750
|
+
if (task.subtask_of) throw new Error(`${id} is itself a subtask of ${task.subtask_of}; decompose its arc instead`);
|
|
751
|
+
// Creation and authorization share this transaction. Guards see the actual child ids, and any refusal
|
|
752
|
+
// (including a later child's invalid spec) rolls back every child, event, subscription and queued effect.
|
|
753
|
+
const ids = children.map(c => createCard(board, {
|
|
754
|
+
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
755
|
+
subtaskOf: id, workspace: 'scratch', tenant: task.tenant, createdBy: author,
|
|
756
|
+
}).id);
|
|
757
|
+
const move = fire(db, board, entry, id, 'decompose', { roles: sender, payload: { children: ids, author } });
|
|
758
|
+
for (const child of ids) settle(db, board, entry, child, move.effects);
|
|
759
|
+
return { ids, effects: move.effects };
|
|
760
|
+
});
|
|
761
|
+
await runEffects(board, result.effects);
|
|
762
|
+
return result.ids;
|
|
754
763
|
}
|
|
755
764
|
|
|
756
765
|
/** `child` waits for `parent`. */
|
package/board/store.mjs
CHANGED
|
@@ -516,7 +516,7 @@ export function heldConfigWatched(slug, source, watched) {
|
|
|
516
516
|
|
|
517
517
|
export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
|
|
518
518
|
// a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
|
|
519
|
-
if (current().readOnly) return
|
|
519
|
+
if (current().readOnly) return openBoardSnapshot(root, slug);
|
|
520
520
|
if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
|
|
521
521
|
let entry = heldBoards.get(slug);
|
|
522
522
|
if (!entry) {
|
|
@@ -553,7 +553,7 @@ function heldHandle(entry, slug) {
|
|
|
553
553
|
* reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
|
|
554
554
|
* (listing.mjs byOwner).
|
|
555
555
|
*/
|
|
556
|
-
function
|
|
556
|
+
export function openBoardSnapshot(root, slug) {
|
|
557
557
|
const use = backingOf(root, slug);
|
|
558
558
|
if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
559
559
|
const path = backingPaths(root, slug)[use];
|
package/board/workflow.mjs
CHANGED
|
@@ -382,6 +382,14 @@ function stateRequestOf(db, id) {
|
|
|
382
382
|
|
|
383
383
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
384
384
|
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
385
|
+
/** Card bodies use Markdown headings and standalone labels such as `Acceptance:` and `Tasks:`. Every label ends
|
|
386
|
+
* the preceding section, so a task or another section's checkbox never acquires Acceptance permissions. */
|
|
387
|
+
export function cardSectionHeading(line) {
|
|
388
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
389
|
+
if (heading) return heading[1].replace(/:$/, '').toLowerCase();
|
|
390
|
+
const label = /^\s*([A-Za-z][A-Za-z0-9 _-]*):\s*$/.exec(line);
|
|
391
|
+
return label ? label[1].trim().toLowerCase() : null;
|
|
392
|
+
}
|
|
385
393
|
/** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
|
|
386
394
|
* and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
|
|
387
395
|
* needs, added after it); no other line added, removed or changed. */
|
|
@@ -393,8 +401,8 @@ function onlyTicks(was, body) {
|
|
|
393
401
|
// an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
|
|
394
402
|
let section = null;
|
|
395
403
|
return before.every((line, i) => {
|
|
396
|
-
const heading =
|
|
397
|
-
if (heading) section = heading
|
|
404
|
+
const heading = cardSectionHeading(line);
|
|
405
|
+
if (heading !== null) section = heading;
|
|
398
406
|
if (line === after[i]) return true;
|
|
399
407
|
if (section !== 'acceptance') return false;
|
|
400
408
|
const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
|
|
@@ -487,8 +495,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
487
495
|
const lines = String(task.body ?? '').split('\n');
|
|
488
496
|
let acceptance = false;
|
|
489
497
|
return lines.every((line) => {
|
|
490
|
-
const heading =
|
|
491
|
-
if (heading) acceptance = heading
|
|
498
|
+
const heading = cardSectionHeading(line);
|
|
499
|
+
if (heading !== null) acceptance = heading === 'acceptance';
|
|
492
500
|
if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
|
|
493
501
|
return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
|
|
494
502
|
});
|
|
@@ -1055,7 +1063,7 @@ const ACTIONS = {
|
|
|
1055
1063
|
else runSession(ctx, (s) => ({ ...s, [counter]: 0 }));
|
|
1056
1064
|
}
|
|
1057
1065
|
},
|
|
1058
|
-
end_run(ctx, { outcome, summary = null, error = null } = {}) {
|
|
1066
|
+
end_run(ctx, { outcome, summary = null, error = null, close_session = true } = {}) {
|
|
1059
1067
|
const row = one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id);
|
|
1060
1068
|
const t = now();
|
|
1061
1069
|
if (row?.current_run_id) {
|
|
@@ -1063,10 +1071,15 @@ const ACTIONS = {
|
|
|
1063
1071
|
retireSupersededRuns(ctx.db, ctx.id, runId, json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode);
|
|
1064
1072
|
run(ctx.db, `UPDATE task_runs SET status = ?, outcome = ?, summary = COALESCE(?, summary), error = COALESCE(?, error), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
|
|
1065
1073
|
WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
|
|
1066
|
-
requestSessionClose(ctx.db, ctx.id, { runId });
|
|
1067
1074
|
ctx.runId = runId;
|
|
1068
1075
|
ctx.lastOutcome = outcome;
|
|
1069
|
-
ctx
|
|
1076
|
+
runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
|
|
1077
|
+
// A declared completion may end the attempt while retaining its role session. In particular, its own caller
|
|
1078
|
+
// must be able to finish and return before the board starts the independent review.
|
|
1079
|
+
if (close_session) {
|
|
1080
|
+
requestSessionClose(ctx.db, ctx.id, { runId });
|
|
1081
|
+
ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
|
|
1082
|
+
}
|
|
1070
1083
|
} else if (summary || ctx.scope.event?.metadata) {
|
|
1071
1084
|
// an outcome reached with no run open (a person completing a card nobody claimed)
|
|
1072
1085
|
const task = one(ctx.db, 'SELECT assignee FROM tasks WHERE id = ?', ctx.id);
|
|
@@ -1153,8 +1166,12 @@ const ACTIONS = {
|
|
|
1153
1166
|
if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
|
|
1154
1167
|
},
|
|
1155
1168
|
decompose(ctx, said) {
|
|
1156
|
-
//
|
|
1157
|
-
|
|
1169
|
+
// Native arc tasks are worked by the arc's session, never dispatched as prerequisite cards of their own.
|
|
1170
|
+
// Retain the older workflow's dependency action only for separately declared cards.
|
|
1171
|
+
for (const child of said?.children ?? []) {
|
|
1172
|
+
if (one(ctx.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id=?', child)?.subtask_of === ctx.id) continue;
|
|
1173
|
+
run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
|
|
1174
|
+
}
|
|
1158
1175
|
},
|
|
1159
1176
|
// card ids a handoff names that no card has (Hermes flags them the same way)
|
|
1160
1177
|
check_references(ctx, said) {
|
package/board/workspace.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Filesystem/RPC work happens before a board transaction; the ownership check is pure.
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
5
5
|
import { inspectCheckout } from '@volter/teams/checkout';
|
|
@@ -130,23 +130,34 @@ export async function checkWorkspace(board, task) {
|
|
|
130
130
|
if (task.workspace_kind === 'scratch') return null;
|
|
131
131
|
task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
|
|
132
132
|
const facts = await resolveWorkspace(task), peers = [];
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
|
|
134
|
+
// New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
|
|
135
|
+
// once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
|
|
136
|
+
{
|
|
137
|
+
for (const row of board.db.prepare(`SELECT t.id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
|
|
135
138
|
if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
|
|
136
139
|
let other;
|
|
137
|
-
try { other = await resolveWorkspace({ ...row,
|
|
138
|
-
if (
|
|
140
|
+
try { other = await resolveWorkspace({ ...row, handle: cardHandleOf(board, row.id) }); } catch { continue; } // Its retained path/reservation is still checked below.
|
|
141
|
+
if ((row.workspace_kind === 'worktree' ? other.path : other.root) === workspaceRoot) peers.push(row.id);
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
|
-
const checked = { ...facts,
|
|
144
|
+
const checked = { ...facts, workspaceRoot, peers };
|
|
142
145
|
assertWorkspaceOwner(board.db, task, checked);
|
|
143
146
|
return checked;
|
|
144
147
|
}
|
|
148
|
+
const workspacePath = (path, local) => {
|
|
149
|
+
if (!path) return null;
|
|
150
|
+
if (local) { try { return realpathSync(path); } catch { /* a pre-launch reservation may not exist yet */ } }
|
|
151
|
+
return path.replace(/\/+$/, '');
|
|
152
|
+
};
|
|
145
153
|
export function assertWorkspaceOwner(db, task, checked) {
|
|
146
|
-
if (!checked?.
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
if (!checked?.workspaceRoot) return;
|
|
155
|
+
const machine = normalMachine(task.machine ?? localMachine()), local = machine === localMachine();
|
|
156
|
+
const path = workspacePath(checked.workspaceRoot, local);
|
|
157
|
+
const conflict = db.prepare(`SELECT t.id,t.workspace_path,c.machine,c.workspace_root FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch'`).all()
|
|
158
|
+
.find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === machine &&
|
|
159
|
+
(workspacePath(row.workspace_root, local) === path || workspacePath(row.workspace_path, local) === path || checked.peers?.includes(row.id)));
|
|
160
|
+
if (conflict) throw new Error(`workspace ${checked.path} is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();
|
package/board-input.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** Read a remote card write's selected text file (or stdin) on the caller's machine. */
|
|
2
2
|
export function prepareBoardText(argv: string[]): { argv: string[]; stdin: string | null };
|
|
3
|
+
/** Read a caller-local attachment in bounded requests, ending with its digest. */
|
|
4
|
+
export function prepareBoardInputs(argv: string[]): Generator<{ argv: string[]; stdin: string | null }>;
|
package/board-input.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// A remote card write reads its text on the caller's machine, before the board door is called.
|
|
2
2
|
// The board's own argument parser decides which file is used; its last value wins.
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { basename } from 'node:path';
|
|
4
6
|
import { parse } from './board/args.mjs';
|
|
5
7
|
|
|
6
8
|
/** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
|
|
@@ -14,3 +16,33 @@ export function prepareBoardText(argv) {
|
|
|
14
16
|
// reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
|
|
15
17
|
return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
|
|
16
18
|
}
|
|
19
|
+
|
|
20
|
+
/** Each yielded request fits one board frame. Only the last answer is the attachment receipt. */
|
|
21
|
+
export function* prepareBoardInputs(argv) {
|
|
22
|
+
const args = parse(argv);
|
|
23
|
+
if (args._[0] !== 'attach' || args.help) { yield prepareBoardText(argv); return; }
|
|
24
|
+
if (args._.length !== 3 || args.upload !== undefined) throw new Error('attach needs a card id and a local file');
|
|
25
|
+
const source = openSync(args._[2], 'r');
|
|
26
|
+
try {
|
|
27
|
+
const before = fstatSync(source, { bigint: true });
|
|
28
|
+
if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('attach needs a regular file of supported size');
|
|
29
|
+
const upload = randomUUID(), size = Number(before.size), hash = createHash('sha256');
|
|
30
|
+
const command = ['attach', args._[1], '-', '--upload', upload];
|
|
31
|
+
if (args.json) command.push('--json');
|
|
32
|
+
for (const key of ['name', 'content-type', 'author']) if (args[key] !== undefined) command.push(`--${key}`, args[key]);
|
|
33
|
+
const input = body => ({ argv: command, stdin: JSON.stringify(body) });
|
|
34
|
+
yield input({ action: 'begin', size, name: args.name ?? basename(args._[2]) });
|
|
35
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
36
|
+
for (let offset = 0; offset < size;) {
|
|
37
|
+
const count = readSync(source, buffer, 0, Math.min(buffer.length, size - offset), offset);
|
|
38
|
+
if (count <= 0) throw new Error('attachment ended during upload; no attachment was committed');
|
|
39
|
+
const bytes = buffer.subarray(0, count); hash.update(bytes);
|
|
40
|
+
yield input({ action: 'append', offset, bytes: bytes.toString('base64') });
|
|
41
|
+
offset += count;
|
|
42
|
+
}
|
|
43
|
+
const after = fstatSync(source, { bigint: true });
|
|
44
|
+
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
|
|
45
|
+
throw new Error('attachment changed during upload; no attachment was committed');
|
|
46
|
+
yield input({ action: 'commit', sha256: hash.digest('hex') });
|
|
47
|
+
} finally { closeSync(source); }
|
|
48
|
+
}
|
package/hermes-cli.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
|
|
2
2
|
// skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
|
|
3
3
|
// against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
|
|
4
|
-
// pinned `hermes` on its `PATH
|
|
5
|
-
// in `cron/executions.db`) the orchestrator records through Hermes's own function.
|
|
4
|
+
// pinned `hermes` on its `PATH`. Native cron incidents use the owning interchange codec, in the same executions store.
|
|
6
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
7
6
|
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
7
|
import { delimiter, dirname, join } from 'node:path';
|
|
9
8
|
import { parseDotenv } from './orchestration.mjs';
|
|
10
9
|
import { locateHermes } from './apply/doors.mjs';
|
|
10
|
+
import { recordCronIncident } from '@volter/supercode-interchange/cron-incidents';
|
|
11
|
+
import { redactText } from '@volter/teams/redact';
|
|
11
12
|
|
|
12
13
|
/** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
|
|
13
14
|
function python(install, code, payload, env, timeout) {
|
|
@@ -48,23 +49,10 @@ export function hermesToolEnv(profileDir) {
|
|
|
48
49
|
return env;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*/
|
|
56
|
-
export function recordIncident(profileDir, { job_id, job_name = null, error }) {
|
|
57
|
-
const install = hermesInstall();
|
|
58
|
-
if (!install?.python) return { id: null, why: 'no Hermes install on PATH' };
|
|
59
|
-
const code = 'import json,sys\nfrom cron.incidents import upsert_incident\na=json.loads(sys.stdin.read())\nprint(json.dumps(upsert_incident(a["job_id"], a["error"], job_name=a.get("job_name"))))';
|
|
60
|
-
const run = spawnSync(install.python, ['-c', code], {
|
|
61
|
-
input: JSON.stringify({ job_id, job_name, error: String(error ?? 'failed') }),
|
|
62
|
-
encoding: 'utf8', timeout: 30_000, cwd: install.source,
|
|
63
|
-
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source },
|
|
64
|
-
});
|
|
65
|
-
if (run.status !== 0) return { id: null, why: (run.stderr || run.error?.message || '').trim().slice(-300) };
|
|
66
|
-
const [id] = JSON.parse(run.stdout.trim().split('\n').pop());
|
|
67
|
-
return { id };
|
|
52
|
+
/** Record native cron failure grouping without requiring a separate Hermes Python installation. */
|
|
53
|
+
export function recordIncident(profileDir, { job_id, error }) {
|
|
54
|
+
try { return recordCronIncident(profileDir, { job_id, error: String(error ?? 'failed'), redacted_error: redactText(String(error ?? 'failed')) }); }
|
|
55
|
+
catch (error) { return { id: null, why: error.message }; }
|
|
68
56
|
}
|
|
69
57
|
|
|
70
58
|
// Access through Hermes's own pairing store and writers (docs/plans/hermes-compat.md row 8): a code is
|
package/index.d.ts
CHANGED
|
@@ -443,7 +443,7 @@ export interface McpServerLaunch { name: string; command: string; arguments: str
|
|
|
443
443
|
export function mountOrchestratorMcp(o: {
|
|
444
444
|
root: Path; profile: Profile; harness: string; surfaceKey?: string | null;
|
|
445
445
|
launch?: { program: string; arguments?: string[]; env?: Record<string, string> };
|
|
446
|
-
socketPath?: Path;
|
|
446
|
+
socketPath?: Path; program?: string;
|
|
447
447
|
/** The Teams id of the agent the session is; the rh2-agent record installed for that id (`--agent`) is mounted beside
|
|
448
448
|
* the orchestrator server as `rh2-agent`. */
|
|
449
449
|
agentId?: string | null;
|
package/loop.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
8
8
|
import { basename, join, dirname } from 'node:path';
|
|
9
|
+
import { parseSessionWire, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
|
|
9
10
|
import { step, leaseId } from './reducer.mjs';
|
|
10
11
|
import * as events from './events.mjs';
|
|
11
12
|
import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
|
|
@@ -278,9 +279,18 @@ export class Orchestrator {
|
|
|
278
279
|
try {event={...event,identity:await this.identityRecord.observe({observation,location:JSON.stringify(event.key),at:Date.parse(event.at),name:event.sender_name,evidence:{adapter:event.adapter,message_id:event.message_id}})};}
|
|
279
280
|
catch(error){event={...event,identity:{observation,principal:null,classification:'unresolved',label:'unresolved'}};this.log('warn',`identity observation unavailable: ${error.message}`);}
|
|
280
281
|
}
|
|
282
|
+
const before = parseSessionWire(stringifySessionWire(this.state));
|
|
281
283
|
const effects = step(this.state, event);
|
|
282
|
-
|
|
284
|
+
const firstSave = effects.findIndex(effect => effect.type === 'save');
|
|
285
|
+
if (firstSave >= 0) {
|
|
286
|
+
// Persist the reducer's claims before any external action. A refused save must leave no in-memory gate or
|
|
287
|
+
// hosted-fire marker behind: otherwise the next tick thinks an execution that never started is still alive.
|
|
288
|
+
try { await this.save(); }
|
|
289
|
+
catch (error) { this.state = before; throw error; }
|
|
290
|
+
}
|
|
291
|
+
for (const [index, effect] of effects.entries()) {
|
|
283
292
|
this.effectLog.push(effect);
|
|
293
|
+
if (index === firstSave) continue;
|
|
284
294
|
await this.perform(effect);
|
|
285
295
|
}
|
|
286
296
|
return effects;
|
package/mcp/mount.mjs
CHANGED
|
@@ -24,13 +24,19 @@ import { SOCKET_ENV, socketPathFor } from './server.mjs';
|
|
|
24
24
|
import { mcpServers } from '../hermes-home.mjs';
|
|
25
25
|
import { KANBAN_ENV } from '../hermes-cli.mjs';
|
|
26
26
|
import { installedAgentRecord } from '../installed-apps.mjs';
|
|
27
|
+
import { supercodeHome } from '@volter/teams/home';
|
|
27
28
|
|
|
28
29
|
export { MCP_DOORS, doorFor } from '@volter/supercode-harness-sdk/mcp-doors';
|
|
29
30
|
|
|
30
|
-
/** The stdio server this
|
|
31
|
+
/** The stdio server `supercode orchestrator mcp-server` runs (the mount names that door, never this path). */
|
|
31
32
|
export const SERVER_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'server.mjs');
|
|
32
|
-
/** The
|
|
33
|
-
|
|
33
|
+
/** The machine's stable `supercode` (the source pin's shim in the supercode home, else the one on PATH), which runs
|
|
34
|
+
* every program a session mounts (`orchestrator mcp-server`, `orchestrator apps mcp`): a config that names it names no
|
|
35
|
+
* release, so the pin that retires a tree leaves it working. */
|
|
36
|
+
function stableSupercode() {
|
|
37
|
+
const shim = join(supercodeHome(), 'bin', process.platform === 'win32' ? 'supercode.cmd' : 'supercode');
|
|
38
|
+
return existsSync(shim) ? shim : 'supercode';
|
|
39
|
+
}
|
|
34
40
|
/** The MCP server an agent with an installed rh2-agent gets: `rh2 mcp` as that agent (its own name, so the user's own
|
|
35
41
|
* `rh2` server, whatever it holds, is neither replaced nor relied on). */
|
|
36
42
|
export const RH2_AGENT_SERVER = 'rh2-agent';
|
|
@@ -40,7 +46,7 @@ export const RH2_AGENT_SERVER = 'rh2-agent';
|
|
|
40
46
|
* Teams id): a record is found by that id alone, so an agent sharing another's name is never mounted with its record. */
|
|
41
47
|
function rh2AgentServer(root, agentId, program) {
|
|
42
48
|
if (!installedAgentRecord(root, agentId)) return null;
|
|
43
|
-
return { command:
|
|
49
|
+
return { command: stableSupercode(), args: ['orchestrator', 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId, '--node', program] };
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'session' : 'session');
|
|
@@ -55,13 +61,12 @@ const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0
|
|
|
55
61
|
* @param {string|null} o.surfaceKey the surface this session is bound to
|
|
56
62
|
* @param {object|undefined} o.launch the launch the loop resolved, if any
|
|
57
63
|
* @param {string} [o.socketPath] defaults to `<root>/orchestrator.sock`
|
|
58
|
-
* @param {string} [o.
|
|
59
|
-
* @param {string} [o.program] the interpreter, defaults to this node
|
|
64
|
+
* @param {string} [o.program] the node the server runs with, defaults to this node
|
|
60
65
|
* @param {string|null} [o.agentId] the Teams id of the agent whose own session this is, whose installed rh2-agent is
|
|
61
66
|
* mounted beside; null for a worker acting for an agent
|
|
62
67
|
* @returns {{door: string, ran: string, launch: object|undefined, mcp_servers: object[]|null, path: string|null}}
|
|
63
68
|
*/
|
|
64
|
-
export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath,
|
|
69
|
+
export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, program = process.execPath, vault = new Map(), task = null, agentId = null }) {
|
|
65
70
|
const door = doorFor(harness);
|
|
66
71
|
// the profile's own servers (`.mcp.json`, the workers' form; docs/plans/hermes-compat.md row 4), mounted
|
|
67
72
|
// beside this one through the same door, `${VAR}` resolved from the profile's vault
|
|
@@ -81,6 +86,9 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
81
86
|
// board's tools for it (docs/plans/hermes-compat.md row 14)
|
|
82
87
|
const sessionEnv = { ...env, ...(surfaceKey ? { [ORCH_ENV.surface]: surfaceKey } : {}), ...(task ?? {}) };
|
|
83
88
|
|
|
89
|
+
// the server through the stable supercode, never this release's server.mjs: a pin that retires this tree leaves the
|
|
90
|
+
// session's next server start (a resume, a reconnect) working
|
|
91
|
+
const server = { command: stableSupercode(), args: ['orchestrator', 'mcp-server', '--node', program] };
|
|
84
92
|
switch (door) {
|
|
85
93
|
case 'flag': {
|
|
86
94
|
if (!launch) {
|
|
@@ -88,14 +96,14 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
88
96
|
}
|
|
89
97
|
const file = join(home, `mcp-orchestrator-${slug(surfaceKey)}.json`);
|
|
90
98
|
mkdirSync(home, { recursive: true });
|
|
91
|
-
writeMcpConfigFile(file, { ...own, [SERVER_NAME]: {
|
|
99
|
+
writeMcpConfigFile(file, { ...own, [SERVER_NAME]: { ...server, env: sessionEnv } });
|
|
92
100
|
return { door, ran: `wrote ${file} and passed --mcp-config`, launch: withMcpConfigFlag(launch, file), mcp_servers: null, path: file };
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
case 'toml': {
|
|
96
104
|
const file = join(home, 'config.toml');
|
|
97
105
|
mkdirSync(home, { recursive: true });
|
|
98
|
-
const block = tomlBlock(
|
|
106
|
+
const block = tomlBlock(server, env);
|
|
99
107
|
let text = upsertTomlBlock(existsSync(file) ? readFileSync(file, 'utf8') : '', block, SERVER_NAME);
|
|
100
108
|
const written = ownNames(home);
|
|
101
109
|
for (const name of written) if (!(name in own)) text = removeTomlBlock(text, name);
|
|
@@ -115,7 +123,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
115
123
|
for (const name of ownNames(home)) if (!(name in own)) delete config.mcp[name];
|
|
116
124
|
for (const [name, server] of Object.entries(own)) config.mcp[name] = opencodeEntry(server);
|
|
117
125
|
recordOwnNames(home, Object.keys(own));
|
|
118
|
-
config.mcp[SERVER_NAME] = opencodeEntry({
|
|
126
|
+
config.mcp[SERVER_NAME] = opencodeEntry({ ...server, env });
|
|
119
127
|
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
120
128
|
return { door, ran: `wrote mcp.${SERVER_NAME} into ${file}`, launch, mcp_servers: null, path: file };
|
|
121
129
|
}
|
|
@@ -131,7 +139,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
131
139
|
launch,
|
|
132
140
|
mcp_servers: [
|
|
133
141
|
...Object.entries(own).filter(([, server]) => server.command).map(([name, server]) => acpEntry(name, server)),
|
|
134
|
-
acpEntry(SERVER_NAME, {
|
|
142
|
+
acpEntry(SERVER_NAME, { ...server, env: sessionEnv }),
|
|
135
143
|
],
|
|
136
144
|
path: null,
|
|
137
145
|
};
|
|
@@ -139,11 +147,11 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
/** The orchestrator's own `[mcp_servers.orchestrator]` block. */
|
|
142
|
-
function tomlBlock(
|
|
150
|
+
function tomlBlock({ command, args }, env) {
|
|
143
151
|
const lines = [
|
|
144
152
|
`[mcp_servers.${SERVER_NAME}]`,
|
|
145
|
-
`command = ${tomlString(
|
|
146
|
-
`args = [${tomlString(
|
|
153
|
+
`command = ${tomlString(command)}`,
|
|
154
|
+
`args = [${args.map(tomlString).join(', ')}]`,
|
|
147
155
|
];
|
|
148
156
|
for (const [k, v] of Object.entries(env)) lines.push(`env.${k} = ${tomlString(v)}`);
|
|
149
157
|
// The per-session half rides codex's own env allowlist: the server is a child
|
package/orchestration.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
28
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
29
29
|
import { emptyRuntime, persistedView } from './ir.mjs';
|
|
30
30
|
import { lockedNative } from './source-lock.mjs';
|
|
31
|
+
import { SESSION_READ_REQUEST_UNITS, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
|
|
31
32
|
|
|
32
33
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
34
|
|
|
@@ -150,7 +151,7 @@ export async function load(root) {
|
|
|
150
151
|
const { orchestration, vault_keys } = await through((d) => d.orchestrationLoad({ root }));
|
|
151
152
|
let vault = readVault(orchestration);
|
|
152
153
|
if (vault_keys.some((k) => !vault.has(k))) {
|
|
153
|
-
await
|
|
154
|
+
await save(stateOf(orchestration), root);
|
|
154
155
|
vault = readVault(orchestration);
|
|
155
156
|
const missing = vault_keys.filter((k) => !vault.has(k));
|
|
156
157
|
if (missing.length) throw new Error(`${root}: ${missing.join(', ')} referenced but not in .env after normalization`);
|
|
@@ -172,10 +173,36 @@ export async function read(root) {
|
|
|
172
173
|
* written as the home last held it, or left out when it held none, and none refuses the rest of the save.
|
|
173
174
|
*/
|
|
174
175
|
export async function save(state, root = state.root) {
|
|
175
|
-
const
|
|
176
|
+
const params = boundedRuntimeSave(state, root);
|
|
177
|
+
const saved = await through((d) => d.orchestrationSave(params));
|
|
178
|
+
// Apply retention in memory only after the owning door accepted it.
|
|
179
|
+
for (const [name, profile] of Object.entries(params.orchestration.profiles)) state.profiles[name].fires = profile.fires;
|
|
176
180
|
return { rejected: Array.isArray(saved?.rejected) ? saved.rejected : [] };
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
// A per-profile row count cannot bound the whole home's request. Keep active fires and all configuration, and
|
|
184
|
+
// remove the oldest terminal history across profiles until the actual native wire value fits, with envelope room.
|
|
185
|
+
export function boundedRuntimeSave(state, root = state.root) {
|
|
186
|
+
const orchestration = { ...persistedView(state), profiles: Object.fromEntries(Object.entries(state.profiles).map(([name, profile]) => [name, { ...profile, fires: [...profile.fires] }])) };
|
|
187
|
+
const params = { root, orchestration, preserve_workflow: true };
|
|
188
|
+
const budget = SESSION_READ_REQUEST_UNITS - 16 * 1024;
|
|
189
|
+
let units = Buffer.byteLength(stringifySessionWire(params));
|
|
190
|
+
if (units <= budget) return params;
|
|
191
|
+
const terminal = Object.entries(orchestration.profiles).flatMap(([name, profile]) => profile.fires
|
|
192
|
+
.filter(fire => !['claimed', 'running'].includes(fire.status)).map(fire => ({ name, fire })));
|
|
193
|
+
terminal.sort((a, b) => String(a.fire.claimed_at ?? '').localeCompare(String(b.fire.claimed_at ?? '')) || String(a.fire.id).localeCompare(String(b.fire.id)));
|
|
194
|
+
const drop = new Set();
|
|
195
|
+
for (const { fire } of terminal) {
|
|
196
|
+
if (units <= budget) break;
|
|
197
|
+
drop.add(fire);
|
|
198
|
+
units -= Buffer.byteLength(stringifySessionWire(fire)) + 1;
|
|
199
|
+
}
|
|
200
|
+
for (const profile of Object.values(orchestration.profiles)) profile.fires = profile.fires.filter(fire => !drop.has(fire));
|
|
201
|
+
const actual = Buffer.byteLength(stringifySessionWire(params));
|
|
202
|
+
if (actual > budget) throw new Error(`orchestrator runtime save needs ${actual} bytes after terminal-history retention (limit ${budget}); configuration and active jobs were preserved; no execution was started`);
|
|
203
|
+
return params;
|
|
204
|
+
}
|
|
205
|
+
|
|
179
206
|
/** Another harness's home as a state value (no secret value; `vault_keys` names the `.env` keys it would need). */
|
|
180
207
|
export async function compile(from, home) {
|
|
181
208
|
const { orchestration, vault_keys } = await through((d) => d.orchestrationCompile({ from, home }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.212",
|
|
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,7 +32,7 @@
|
|
|
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.347",
|
|
36
36
|
"@volter/supercode-terminal": "^0.3.10",
|
|
37
37
|
"@volter/supercode-harness-sdk": "^0.3.104",
|
|
38
38
|
"@volter/teams": "0.2.99",
|
|
@@ -61,6 +61,5 @@
|
|
|
61
61
|
"board",
|
|
62
62
|
"apply",
|
|
63
63
|
"README.md"
|
|
64
|
-
]
|
|
65
|
-
"gitHead": "38f4a38369c31949d4d81fd3520a6aa678582ead"
|
|
64
|
+
]
|
|
66
65
|
}
|