@volter/supercode-orchestrator 0.5.106 → 0.5.108
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/activation.mjs +3 -0
- package/board/doors.mjs +5 -0
- package/board/machine-fleet.mjs +12 -2
- package/board/record-view.mjs +93 -0
- package/kanban-worker.mjs +42 -4
- package/package.json +2 -2
package/activation.mjs
CHANGED
|
@@ -176,6 +176,9 @@ export const MODEL_ARGUMENTS = Object.freeze({
|
|
|
176
176
|
'claude-code': (model) => ['--model', model],
|
|
177
177
|
codex: (model) => ['-c', `model=${JSON.stringify(model)}`],
|
|
178
178
|
gemini: (model) => ['-m', model],
|
|
179
|
+
// The Runhuman harness runs a routine (runhuman-2 ADR 0038 §13): the worker's model names it, `routine:<org>/<name>`;
|
|
180
|
+
// with none, the routine is the `.runhuman` folder in the worker's cwd. The card role's agent is the run's agent.
|
|
181
|
+
runhuman: (model) => ['--routine', model],
|
|
179
182
|
});
|
|
180
183
|
|
|
181
184
|
/**
|
package/board/doors.mjs
CHANGED
|
@@ -64,6 +64,11 @@ export async function machineCall(machine, method, params = {}, { timeoutMs = 60
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export async function machineRecordStream(params = {}) {
|
|
68
|
+
const door = await opened(null);
|
|
69
|
+
return door.channel.open('machine-record-changes', params);
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
/** Let go of every held channel (the owner stopping). */
|
|
68
73
|
export async function closeDoors() {
|
|
69
74
|
for (const [key, opening] of [...held]) {
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// sessions under them not read) or was never read is `unknown` (its runs are kept, as an unread machine's always were), unless its record says it was unlinked from the
|
|
9
9
|
// team: then it is offline. This machine's own record not being given is no reading at all.
|
|
10
10
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
11
|
-
import { machineCall } from './doors.mjs';
|
|
11
|
+
import { machineCall, machineRecordStream } from './doors.mjs';
|
|
12
|
+
import { RecordView } from './record-view.mjs';
|
|
13
|
+
const recordView = new RecordView(machineRecordStream);
|
|
12
14
|
const DEADLINE_MS = 10_000;
|
|
13
15
|
const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms).unref?.())]);
|
|
14
16
|
// One machine's reading, from its held record: its panes, what a board launch needs of it, and the session under each
|
|
@@ -32,7 +34,15 @@ export async function readMachineFleet(machines, local) {
|
|
|
32
34
|
const names = [...new Set([local, ...machines])];
|
|
33
35
|
const started = performance.now();
|
|
34
36
|
let held = null, heldError = null;
|
|
35
|
-
try {
|
|
37
|
+
try {
|
|
38
|
+
try { held = await within(recordView.read(DEADLINE_MS), DEADLINE_MS); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (!String(error.message).includes('unknown stream machine-record-changes')) throw error;
|
|
41
|
+
// Supported older daemons have only the snapshot door. A refusal or a
|
|
42
|
+
// failed current stream never falls back to data of uncertain freshness.
|
|
43
|
+
held = (await within(machineCall(null, 'harness.v1.machine.records', {}), DEADLINE_MS))?.records ?? {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
36
46
|
catch (error) { heldError = String(error?.message ?? error).slice(0, 300); }
|
|
37
47
|
const rows = names.map((name) => heldError ? { machine: name, error: `this machine's daemon did not give the records it holds: ${heldError}`, code: 'unavailable' } : readOne(name, held[name]));
|
|
38
48
|
noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'}, held records)`, performance.now() - started);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Resident local consumer of the daemon's committed record rows. Disconnects
|
|
2
|
+
// preserve the last applied cursor, but held data is stale until caught up.
|
|
3
|
+
export class RecordView {
|
|
4
|
+
constructor(open) {
|
|
5
|
+
this.open = open; this.records = {}; this.positions = new Map();
|
|
6
|
+
this.cursor = null; this.stream = null; this.waiters = new Set();
|
|
7
|
+
this.ready = false; this.heardAt = 0;
|
|
8
|
+
}
|
|
9
|
+
async read(timeoutMs) {
|
|
10
|
+
const deadline = Date.now() + timeoutMs;
|
|
11
|
+
if (this.stream && Date.now() - this.heardAt > 45_000) this.stream.close('its record stream went quiet');
|
|
12
|
+
if (!this.stream && !this.opening) {
|
|
13
|
+
this.opening = this.open({ after: this.cursor }).then((stream) => {
|
|
14
|
+
this.stream = stream;
|
|
15
|
+
this.ready = false;
|
|
16
|
+
this.heardAt = Date.now();
|
|
17
|
+
stream.on('data', (frame) => {
|
|
18
|
+
try { this.apply(frame); }
|
|
19
|
+
catch (error) { this.fail(error); stream.close(error.message); }
|
|
20
|
+
});
|
|
21
|
+
stream.on('close', (reason) => {
|
|
22
|
+
if (this.stream !== stream) return;
|
|
23
|
+
this.stream = null; this.ready = false;
|
|
24
|
+
this.fail(new Error(reason ?? 'the local record stream closed'));
|
|
25
|
+
});
|
|
26
|
+
}).finally(() => { this.opening = null; });
|
|
27
|
+
}
|
|
28
|
+
if (this.opening) await this.opening;
|
|
29
|
+
if (this.ready) return this.records;
|
|
30
|
+
if (!this.stream) throw this.failure ?? new Error('the local record stream ended before its snapshot');
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const waiter = { resolve, reject };
|
|
33
|
+
waiter.timer = setTimeout(() => {
|
|
34
|
+
this.waiters.delete(waiter);
|
|
35
|
+
reject(new Error('the local record stream did not finish its snapshot or cursor catch-up within its deadline'));
|
|
36
|
+
}, Math.max(0, deadline - Date.now()));
|
|
37
|
+
this.waiters.add(waiter);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
fail(error) {
|
|
41
|
+
this.failure = error;
|
|
42
|
+
for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(error); }
|
|
43
|
+
this.waiters.clear();
|
|
44
|
+
}
|
|
45
|
+
own(key) { return key.role === 'producer' ? key.machine === this.self : key.role === 'holder' && key.machine !== this.self; }
|
|
46
|
+
apply(frame) {
|
|
47
|
+
if (frame.type === 'beat') { this.heardAt = Date.now(); return; }
|
|
48
|
+
if (frame.type === 'snapshot') {
|
|
49
|
+
this.self = frame.machine; this.records = frame.records; this.positions.clear();
|
|
50
|
+
for (const [machine, held] of Object.entries(this.records)) this.positions.set(machine, new Map((held.record?.sessions ?? []).map((row, at) => [row.address, at])));
|
|
51
|
+
this.cursor = frame.cursor; this.ready = true;
|
|
52
|
+
} else if (frame.type === 'changes') {
|
|
53
|
+
if (!this.cursor || frame.cursor?.id !== this.cursor.id) throw new Error('record row changes name a different database; a new snapshot is required');
|
|
54
|
+
const moved = new Set();
|
|
55
|
+
for (const change of frame.changes ?? []) {
|
|
56
|
+
if (change.sequence <= this.cursor.sequence || !this.own(change.key)) continue;
|
|
57
|
+
const { machine, section, key, position } = change.key;
|
|
58
|
+
if (change.topic === 'record-head') {
|
|
59
|
+
if (change.operation === 'delete') { delete this.records[machine]; this.positions.delete(machine); continue; }
|
|
60
|
+
const { record: summary, meta } = change.value;
|
|
61
|
+
const previous = this.records[machine]?.record;
|
|
62
|
+
const record = summary == null ? null : { sessions: [], mail: {}, panes: null, capabilities: null, unread: {}, ...previous, ...summary };
|
|
63
|
+
if (record) { if (!summary.sessions_rows) record.sessions = null; delete record.sessions_rows; }
|
|
64
|
+
this.records[machine] = { record, stream: machine === this.self ? 'self' : meta?.stream ?? 'closed', since: meta?.since ?? null, why: meta?.why ?? null, received_at: meta?.received_at ?? null };
|
|
65
|
+
} else if (change.topic === 'record-row') {
|
|
66
|
+
const held = this.records[machine] ??= { stream: 'closed', record: null };
|
|
67
|
+
const record = held.record ??= { sessions: [], mail: {}, panes: null, capabilities: null, unread: {} };
|
|
68
|
+
const deleted = change.operation === 'delete';
|
|
69
|
+
if (section === 'sessions') {
|
|
70
|
+
const rows = new Map((record.sessions ?? []).map((row) => [row.address, row]));
|
|
71
|
+
const positions = this.positions.get(machine) ?? new Map();
|
|
72
|
+
this.positions.set(machine, positions);
|
|
73
|
+
if (deleted) { rows.delete(key); positions.delete(key); }
|
|
74
|
+
else { rows.set(key, change.value); positions.set(key, position); }
|
|
75
|
+
record.sessions = [...rows.values()]; moved.add(machine);
|
|
76
|
+
} else if (section === 'mail') {
|
|
77
|
+
if (deleted) delete record.mail[key]; else record.mail[key] = change.value;
|
|
78
|
+
} else record[section] = deleted ? null : change.value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const machine of moved) {
|
|
82
|
+
const positions = this.positions.get(machine);
|
|
83
|
+
this.records[machine]?.record?.sessions?.sort((a, b) => (positions.get(a.address) ?? 0) - (positions.get(b.address) ?? 0) || a.address.localeCompare(b.address));
|
|
84
|
+
}
|
|
85
|
+
this.cursor = frame.cursor; this.ready = !frame.more;
|
|
86
|
+
} else throw new Error(`unrecognized record-view frame ${frame.type}`);
|
|
87
|
+
this.heardAt = Date.now();
|
|
88
|
+
if (this.ready) {
|
|
89
|
+
for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.resolve(this.records); }
|
|
90
|
+
this.waiters.clear();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
package/kanban-worker.mjs
CHANGED
|
@@ -19,6 +19,9 @@ import { pinnedSkills, taskProtocol } from './board/worker-prompt.mjs';
|
|
|
19
19
|
import { mountOrchestratorMcp } from './mcp/mount.mjs';
|
|
20
20
|
import { normalizeWorkerEvent, widensSandbox } from './loop.mjs';
|
|
21
21
|
import { askBoardOwner, callerFacts, unansweredText } from '@volter/teams/board-owner';
|
|
22
|
+
// A card's own words are read from its board (a routine has no board tools); what the worker writes goes through the board's owner.
|
|
23
|
+
import { DEFAULT_BOARD, openBoard, view } from './board/store.mjs';
|
|
24
|
+
import { getTask } from './board/engine.mjs';
|
|
22
25
|
import { permissionResponse } from './reducer.mjs';
|
|
23
26
|
import { boardGuardArguments } from '@volter/teams/board-process-guard';
|
|
24
27
|
|
|
@@ -85,6 +88,8 @@ export async function runKanbanWorker(argv, env = process.env) {
|
|
|
85
88
|
|
|
86
89
|
async function runTurn({ client, profile, harness, worker, route, root, vault, surfaceKey, runEnv, instructions, task, cwd, args, say }) {
|
|
87
90
|
const descriptor = (await client.supportReport()).harnesses.find((h) => h.id === harness);
|
|
91
|
+
// A worker's folder that is gone (a home moved or swept) fails its launch as the harness program missing; say which.
|
|
92
|
+
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) { say(`hermes-worker: the worker's folder ${cwd} does not exist (the profile's worker.cwd)`); return 2; }
|
|
88
93
|
const base = descriptor?.runtime?.default_launch;
|
|
89
94
|
if (!base) { say(`hermes-worker: ${harness} publishes no launch`); return 2; }
|
|
90
95
|
const reasoning = args.reasoning && harness === 'codex' ? ['-c', `model_reasoning_effort=${JSON.stringify(args.reasoning)}`] : [];
|
|
@@ -98,11 +103,15 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
|
|
|
98
103
|
const policy = approvalPolicy(profile, harness);
|
|
99
104
|
const runtime = await client.startManagedRuntime({ harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}) });
|
|
100
105
|
try { await recordSession(root, task, harness, runtime.handle.runtime_id); } catch (error) { say(`hermes-worker: recording the session on its run: ${error.message}`); }
|
|
101
|
-
let text = '';
|
|
106
|
+
let text = '', last = '';
|
|
102
107
|
// `agent.max_turns` for a worker that takes no cap at launch, held as the daemon holds it (loop.mjs `holdTurnLimit`)
|
|
103
108
|
const counter = turnCounter(profile, harness);
|
|
104
|
-
const outcome = await new Promise((
|
|
109
|
+
const outcome = await new Promise((resolve) => {
|
|
110
|
+
// Settled once: the runtime's close after the turn's final answer is not the worker ending.
|
|
111
|
+
let settled = false;
|
|
112
|
+
const done = (code) => { settled = true; resolve(code); };
|
|
105
113
|
const on = (ev) => {
|
|
114
|
+
if (settled) return;
|
|
106
115
|
if (counter?.count(ev)) {
|
|
107
116
|
say(`hermes-worker: ${counter.cap + 1} model calls in one turn passes agent.max_turns ${counter.cap}; the turn is interrupted`);
|
|
108
117
|
void Promise.resolve(runtime.interrupt?.()).catch((error) => say(`hermes-worker: interrupt failed: ${error.message}`));
|
|
@@ -110,7 +119,7 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
|
|
|
110
119
|
let w = normalizeWorkerEvent(ev);
|
|
111
120
|
if (!w) return;
|
|
112
121
|
if (w.kind === 'final' && counter) w = counter.settle(w);
|
|
113
|
-
if (w.kind === 'text') text += w.block && text ? `\n\n${w.delta}` : w.delta;
|
|
122
|
+
if (w.kind === 'text') { text += w.block && text ? `\n\n${w.delta}` : w.delta; last = w.delta; }
|
|
114
123
|
else if (w.kind === 'request') {
|
|
115
124
|
// nobody attends a dispatched worker: an untrusted server's tool is denied; anything else is answered by Hermes's
|
|
116
125
|
// approval rule in single-query mode, as the daemon's path asks it (loop.mjs): a safe command runs, a dangerous
|
|
@@ -137,13 +146,42 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
|
|
|
137
146
|
runtime.on('event', on);
|
|
138
147
|
runtime.on('closed', on);
|
|
139
148
|
runtime.on('runtimeError', on);
|
|
140
|
-
runtime.sendInput(args.query).catch((error) => { say(`hermes-worker: ${error.message}`); done(1); });
|
|
149
|
+
runtime.sendInput(harness === 'runhuman' ? cardWords(root, task) ?? args.query : args.query).catch((error) => { say(`hermes-worker: ${error.message}`); done(1); });
|
|
141
150
|
});
|
|
142
151
|
if (text) say(text);
|
|
143
152
|
await runtime.close().catch(() => {});
|
|
153
|
+
// A routine has no board tools: its run's answer (the turn's last line, its returns) completes the card it worked.
|
|
154
|
+
if (harness === 'runhuman' && outcome === 0 && task.HERMES_KANBAN_TASK) {
|
|
155
|
+
const answer = last.trim();
|
|
156
|
+
const done = await askBoardOwner(root, {
|
|
157
|
+
// As the card's worker, by its own board tool (its card, run and board name it), as a harness's board tools do.
|
|
158
|
+
tool: { name: 'kanban_complete', args: { summary: answer || 'The run ended with no answer.', ...(answer ? { result: answer } : {}) }, env: task },
|
|
159
|
+
board: task.HERMES_KANBAN_BOARD || null, caller: null, session: null, cwd: root, stdin: null,
|
|
160
|
+
facts: { ...callerFacts(), SUPERCODE_CONTEXT: process.env.SUPERCODE_CONTEXT || 'local' },
|
|
161
|
+
}).catch((error) => ({ code: 1, stderr: error.message }));
|
|
162
|
+
// The tool's own refusal comes back as its JSON (an `error`), not as a failed request.
|
|
163
|
+
const refused = (() => { try { return JSON.parse(done.stdout ?? '{}')?.error; } catch { return undefined; } })();
|
|
164
|
+
if (refused && !done.code) Object.assign(done, { code: 1, stderr: String(refused) });
|
|
165
|
+
if (done.unanswered === true || done.code) { say(`hermes-worker: completing ${task.HERMES_KANBAN_TASK} with the run's answer: ${done.unanswered === true ? unansweredText(done, 'the card\'s completion').trim() : (done.stderr || 'the board owner did not complete it').trim()}`); return 1; }
|
|
166
|
+
}
|
|
144
167
|
return outcome;
|
|
145
168
|
}
|
|
146
169
|
|
|
170
|
+
/**
|
|
171
|
+
* A Runhuman worker's first input: the card's own words (its title and body), which a routine takes as its variables
|
|
172
|
+
* (a ```runhuman-vars block, its `ask`, its links). A routine has no board tools to read the card by its id.
|
|
173
|
+
*/
|
|
174
|
+
function cardWords(root, task) {
|
|
175
|
+
if (!task.HERMES_KANBAN_TASK) return null;
|
|
176
|
+
const board = openBoard(root, task.HERMES_KANBAN_BOARD || DEFAULT_BOARD);
|
|
177
|
+
try {
|
|
178
|
+
const card = view(board, (db) => getTask(db, task.HERMES_KANBAN_TASK));
|
|
179
|
+
return card ? [card.title, card.body].filter(Boolean).join('\n\n') : null;
|
|
180
|
+
} finally {
|
|
181
|
+
board.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
147
185
|
/**
|
|
148
186
|
* The worker's session on its run, where supercode keeps it (`metadata.supercode.session_id`, beside the pid the
|
|
149
187
|
* dispatcher recorded): the harness's own transcript, found and resumed by supercode like any session. Written by the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.108",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.60",
|
|
28
|
-
"@volter/teams": "0.2.
|
|
28
|
+
"@volter/teams": "0.2.45",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|