@volter/supercode-orchestrator 0.5.215 → 0.5.217
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 +71 -9
- package/activation.mjs +47 -11
- package/adapters/chat-sdk.mjs +82 -15
- package/adapters/rh2.mjs +11 -7
- package/agents.mjs +1 -5
- package/bin/agent-channel.mjs +33 -177
- package/bin/connection-runner.mjs +3 -17
- package/bin/orchestrator.mjs +12 -7
- package/bin/runtime.mjs +74 -51
- package/board/call.mjs +2 -1
- package/board/ci.mjs +55 -28
- package/board/cli.mjs +147 -13
- package/board/client.mjs +9 -0
- package/board/dispatch.mjs +45 -15
- package/board/engine.mjs +2 -2
- package/board/fence.mjs +2 -0
- package/board/generation-owner.mjs +294 -0
- package/board/generation.mjs +49 -0
- package/board/listing.mjs +17 -5
- package/board/owner-door-worker.mjs +22 -7
- package/board/owner-door.mjs +2 -2
- package/board/owner-files.mjs +1 -1
- package/board/published.mjs +12 -7
- package/board/reply.mjs +2 -2
- package/board/session-work.mjs +85 -15
- package/connection.mjs +14 -20
- package/cron.mjs +57 -4
- package/daemon-control.d.ts +39 -0
- package/daemon-control.mjs +275 -0
- package/hermes-locks.mjs +13 -3
- package/index.d.ts +9 -4
- package/installed-apps.mjs +35 -14
- package/kanban-worker.mjs +4 -4
- package/lease.mjs +1 -1
- package/loop.mjs +198 -47
- package/mcp/mount.mjs +6 -5
- package/orchestration.mjs +11 -17
- package/package.json +9 -6
- package/reducer.mjs +31 -6
- package/source-lock.mjs +15 -24
- package/webhook.mjs +65 -12
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Incarnation-bound local lifecycle door. A lease PID is never stop authority.
|
|
2
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { createConnection, createServer } from "node:net";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { thisHost, LOCK_FILE } from "./lease.mjs";
|
|
7
|
+
|
|
8
|
+
const LIMIT = 16 * 1024;
|
|
9
|
+
const identityFields = [
|
|
10
|
+
"pid",
|
|
11
|
+
"started_at",
|
|
12
|
+
"root",
|
|
13
|
+
"host",
|
|
14
|
+
"boot",
|
|
15
|
+
"incarnation",
|
|
16
|
+
"launch_request",
|
|
17
|
+
];
|
|
18
|
+
export const newDaemonIncarnation = () => randomBytes(32).toString("hex");
|
|
19
|
+
export function sameDaemon(a, b) {
|
|
20
|
+
return (
|
|
21
|
+
!!a &&
|
|
22
|
+
!!b &&
|
|
23
|
+
identityFields.every((key) => (a[key] ?? null) === (b[key] ?? null))
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
function selected(root, lease) {
|
|
27
|
+
const home = realpathSync(root);
|
|
28
|
+
const here = thisHost();
|
|
29
|
+
if (
|
|
30
|
+
!lease ||
|
|
31
|
+
!Number.isSafeInteger(lease.pid) ||
|
|
32
|
+
lease.pid <= 0 ||
|
|
33
|
+
typeof lease.started_at !== "string" ||
|
|
34
|
+
lease.root !== home ||
|
|
35
|
+
lease.host !== here.host ||
|
|
36
|
+
(lease.boot ?? null) !== here.boot ||
|
|
37
|
+
typeof lease.incarnation !== "string" ||
|
|
38
|
+
!/^[a-f0-9]{64}$/.test(lease.incarnation)
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"orchestrator stop refused: lease has no bound local daemon incarnation for this home",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return home;
|
|
45
|
+
}
|
|
46
|
+
function endpoint(root, lease) {
|
|
47
|
+
const key = createHash("sha256")
|
|
48
|
+
.update(root)
|
|
49
|
+
.update("\0")
|
|
50
|
+
.update(lease.incarnation)
|
|
51
|
+
.digest("hex")
|
|
52
|
+
.slice(0, 24);
|
|
53
|
+
if (process.platform === "win32")
|
|
54
|
+
return `\\\\.\\pipe\\supercode-orchestrator-${key}`;
|
|
55
|
+
const path = join(root, `oc-${key}.sock`);
|
|
56
|
+
if (Buffer.byteLength(path) > (process.platform === "darwin" ? 103 : 107))
|
|
57
|
+
throw new Error(
|
|
58
|
+
"orchestrator control socket path is too long; select a shorter home path",
|
|
59
|
+
);
|
|
60
|
+
return path;
|
|
61
|
+
}
|
|
62
|
+
function current(root) {
|
|
63
|
+
return JSON.parse(readFileSync(join(root, LOCK_FILE), "utf8"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The daemon alone clears its exact issued record while it still holds the home lock. */
|
|
67
|
+
export function clearDaemonLease(root, lease) {
|
|
68
|
+
if (!sameDaemon(current(root), lease))
|
|
69
|
+
throw new Error("orchestrator lease changed; replacement record preserved");
|
|
70
|
+
unlinkSync(join(root, LOCK_FILE));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** No fallback signal or lease write, including on cancellation or an unknown result. */
|
|
74
|
+
async function requestDaemonControl(
|
|
75
|
+
root,
|
|
76
|
+
lease,
|
|
77
|
+
op,
|
|
78
|
+
{ signal, timeoutMs = 5000 } = {},
|
|
79
|
+
) {
|
|
80
|
+
const home = selected(root, lease);
|
|
81
|
+
if (!sameDaemon(current(home), lease))
|
|
82
|
+
throw new Error("orchestrator stop refused: selected lease changed");
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const socket = createConnection(endpoint(home, lease));
|
|
86
|
+
let bytes = Buffer.alloc(0),
|
|
87
|
+
sent = false,
|
|
88
|
+
finished = false;
|
|
89
|
+
const finish = (error, receipt) => {
|
|
90
|
+
if (finished) return;
|
|
91
|
+
finished = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
signal?.removeEventListener("abort", abort);
|
|
94
|
+
socket.destroy();
|
|
95
|
+
if (error)
|
|
96
|
+
reject(
|
|
97
|
+
new Error(
|
|
98
|
+
`orchestrator ${op} ${sent ? "outcome unknown" : "refused before request"} (${lease.incarnation}): ${error.message}`,
|
|
99
|
+
{ cause: error },
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
else resolve(receipt);
|
|
103
|
+
};
|
|
104
|
+
const abort = () => finish(signal.reason ?? new Error("cancelled"));
|
|
105
|
+
const timer = setTimeout(
|
|
106
|
+
() =>
|
|
107
|
+
finish(
|
|
108
|
+
new Error("daemon control deadline expired; no automatic retry"),
|
|
109
|
+
),
|
|
110
|
+
timeoutMs,
|
|
111
|
+
);
|
|
112
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
113
|
+
socket.on("error", (error) => finish(error));
|
|
114
|
+
socket.on("close", () =>
|
|
115
|
+
finish(
|
|
116
|
+
new Error(
|
|
117
|
+
"daemon control closed without a matching completion receipt",
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
socket.on("connect", () => {
|
|
122
|
+
try {
|
|
123
|
+
signal?.throwIfAborted();
|
|
124
|
+
if (!sameDaemon(current(home), lease))
|
|
125
|
+
throw new Error("selected lease changed before request");
|
|
126
|
+
sent = true;
|
|
127
|
+
socket.write(`${JSON.stringify({ version: 1, op, lease })}\n`);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
finish(error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
socket.on("data", (chunk) => {
|
|
133
|
+
bytes = Buffer.concat([bytes, chunk]);
|
|
134
|
+
if (bytes.length > LIMIT)
|
|
135
|
+
return finish(new Error("oversized daemon control receipt"));
|
|
136
|
+
const end = bytes.indexOf(10);
|
|
137
|
+
if (end < 0) return;
|
|
138
|
+
try {
|
|
139
|
+
const receipt = JSON.parse(bytes.subarray(0, end).toString("utf8"));
|
|
140
|
+
if (
|
|
141
|
+
receipt.version !== 1 ||
|
|
142
|
+
!sameDaemon(receipt.daemon, lease) ||
|
|
143
|
+
!(
|
|
144
|
+
op === "describe"
|
|
145
|
+
? ["running", "refused"]
|
|
146
|
+
: ["stopped", "failed", "refused"]
|
|
147
|
+
).includes(receipt.status) ||
|
|
148
|
+
!Array.isArray(receipt.errors) ||
|
|
149
|
+
receipt.errors.some((error) => typeof error !== "string")
|
|
150
|
+
)
|
|
151
|
+
throw new Error("invalid daemon control receipt");
|
|
152
|
+
finish(null, receipt);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
finish(error);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export const requestDaemonStop = (root, lease, options) =>
|
|
160
|
+
requestDaemonControl(root, lease, "stop", options);
|
|
161
|
+
export const describeDaemon = (root, lease, options) =>
|
|
162
|
+
requestDaemonControl(root, lease, "describe", options);
|
|
163
|
+
|
|
164
|
+
/** Separate from the model bridge: its response survives closing that bridge. */
|
|
165
|
+
export async function startDaemonControl({ root, lease, stop, complete }) {
|
|
166
|
+
const home = selected(root, lease),
|
|
167
|
+
path = endpoint(home, lease);
|
|
168
|
+
const sockets = new Map(),
|
|
169
|
+
tasks = new Set();
|
|
170
|
+
let closing = null,
|
|
171
|
+
stopping = false;
|
|
172
|
+
const server = createServer((socket) => {
|
|
173
|
+
sockets.set(socket, false);
|
|
174
|
+
socket.setTimeout(5000, () => socket.destroy());
|
|
175
|
+
socket.on("error", () => {});
|
|
176
|
+
socket.on("close", () => sockets.delete(socket));
|
|
177
|
+
let bytes = Buffer.alloc(0),
|
|
178
|
+
handled = false;
|
|
179
|
+
socket.on("data", (chunk) => {
|
|
180
|
+
if (handled) return;
|
|
181
|
+
bytes = Buffer.concat([bytes, chunk]);
|
|
182
|
+
if (bytes.length > LIMIT) {
|
|
183
|
+
handled = true;
|
|
184
|
+
socket.destroy();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const end = bytes.indexOf(10);
|
|
188
|
+
if (end < 0) return;
|
|
189
|
+
handled = true;
|
|
190
|
+
let request;
|
|
191
|
+
try {
|
|
192
|
+
request = JSON.parse(bytes.subarray(0, end).toString("utf8"));
|
|
193
|
+
// Final admission is synchronous, against this process's immutable issuance
|
|
194
|
+
// and the home's current record; another PID's liveness never participates.
|
|
195
|
+
if (
|
|
196
|
+
closing ||
|
|
197
|
+
(stopping && request.op === "describe") ||
|
|
198
|
+
request.version !== 1 ||
|
|
199
|
+
!["stop", "describe"].includes(request.op) ||
|
|
200
|
+
!sameDaemon(request.lease, lease) ||
|
|
201
|
+
!sameDaemon(current(home), lease)
|
|
202
|
+
)
|
|
203
|
+
throw new Error(
|
|
204
|
+
"selected daemon incarnation is not the current home owner",
|
|
205
|
+
);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
socket.end(
|
|
208
|
+
`${JSON.stringify({ version: 1, daemon: lease, status: "refused", errors: [error.message] })}\n`,
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (request.op === "describe") {
|
|
213
|
+
socket.end(
|
|
214
|
+
`${JSON.stringify({ version: 1, daemon: lease, status: "running", errors: [] })}\n`,
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
sockets.set(socket, true);
|
|
219
|
+
socket.setTimeout(0);
|
|
220
|
+
stopping = true;
|
|
221
|
+
const task = (async () => {
|
|
222
|
+
const receipt = await stop("control");
|
|
223
|
+
await new Promise((done) => {
|
|
224
|
+
if (socket.destroyed) return done();
|
|
225
|
+
socket.once("close", done);
|
|
226
|
+
socket.end(`${JSON.stringify(receipt)}\n`, () => {
|
|
227
|
+
socket.destroy();
|
|
228
|
+
done();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
})();
|
|
232
|
+
tasks.add(task);
|
|
233
|
+
// Completion owns process exit, after every admitted response has flushed.
|
|
234
|
+
void task
|
|
235
|
+
.finally(() => {
|
|
236
|
+
tasks.delete(task);
|
|
237
|
+
setImmediate(complete);
|
|
238
|
+
})
|
|
239
|
+
.catch(() => {});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
await new Promise((resolve, reject) => {
|
|
243
|
+
server.once("error", reject);
|
|
244
|
+
server.listen(path, () => {
|
|
245
|
+
server.removeListener("error", reject);
|
|
246
|
+
resolve();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
server.close();
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
close() {
|
|
257
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: retain and return the single owned close barrier.
|
|
258
|
+
return (closing ??= (async () => {
|
|
259
|
+
const closed = new Promise((resolve, reject) =>
|
|
260
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
261
|
+
);
|
|
262
|
+
for (const [socket, admitted] of sockets)
|
|
263
|
+
if (!admitted) socket.destroy();
|
|
264
|
+
const results = await Promise.allSettled([...tasks]);
|
|
265
|
+
await closed;
|
|
266
|
+
const errors = results
|
|
267
|
+
.filter((row) => row.status === "rejected")
|
|
268
|
+
.map((row) => row.reason);
|
|
269
|
+
if (errors.length)
|
|
270
|
+
throw new AggregateError(errors, "daemon control completion failed");
|
|
271
|
+
// net.Server removes its own Unix socket; no unlink of another incarnation.
|
|
272
|
+
})());
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
package/hermes-locks.mjs
CHANGED
|
@@ -113,10 +113,13 @@ function writeRecord(file, record, flag) {
|
|
|
113
113
|
try { writeSync(fd, JSON.stringify(record)); } finally { closeSync(fd); }
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/** Release
|
|
116
|
+
/** Release this process's token claim; absence is settled, other failures remain failures. */
|
|
117
117
|
export function releaseTokenLock({ scope, identity, env = process.env }) {
|
|
118
118
|
const file = join(tokenLockDir(env), `${scope}-${scopeHash(identity)}.lock`);
|
|
119
|
-
try {
|
|
119
|
+
try {
|
|
120
|
+
if (Number(JSON.parse(readFileSync(file, 'utf8')).pid) !== process.pid) throw new Error('token lock changed owner; retained');
|
|
121
|
+
unlinkSync(file);
|
|
122
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
// The flock itself: Node has none, so a small Python child takes it, writes Hermes's record naming
|
|
@@ -150,13 +153,20 @@ function holdFileLock(path, home, { python, what, tail }) {
|
|
|
150
153
|
child = spawn(python, ['-c', HOLDER, path, JSON.stringify(pidRecord(home))], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
151
154
|
} catch (error) { fail(new Error(`cannot hold ${path}: ${error.message}`)); return; }
|
|
152
155
|
let out = '';
|
|
156
|
+
const ended = new Promise(resolve => child.once('close', (code, signal) => resolve({ code, signal })));
|
|
157
|
+
child.stdin.on('error', () => {}); // release joins the actual holder exit below
|
|
158
|
+
let releasing = null;
|
|
153
159
|
child.on('error', (error) => fail(new Error(`cannot hold ${path} (${python}: ${error.message}); a home is served by one runtime, and this one could not claim it`)));
|
|
154
160
|
child.stdout.on('data', (chunk) => {
|
|
155
161
|
out += chunk;
|
|
156
162
|
if (!out.includes('\n')) return;
|
|
157
163
|
const line = out.split('\n')[0];
|
|
158
164
|
if (line === 'ok') {
|
|
159
|
-
done({ release: () =>
|
|
165
|
+
done({ release: () => releasing ??= (async () => {
|
|
166
|
+
child.stdin.end();
|
|
167
|
+
const result = await ended;
|
|
168
|
+
if (result.code !== 0) throw new Error(`lock holder for ${path} exited ${result.code ?? result.signal}`);
|
|
169
|
+
})() });
|
|
160
170
|
} else {
|
|
161
171
|
let holder = null;
|
|
162
172
|
try { holder = JSON.parse(line.slice('held '.length)); } catch { /* unreadable record */ }
|
package/index.d.ts
CHANGED
|
@@ -145,8 +145,8 @@ export type ChatType = 'dm' | 'group' | 'channel' | 'thread';
|
|
|
145
145
|
|
|
146
146
|
export interface RuntimeState {
|
|
147
147
|
leases: Record<string, { runtime_id: string; connection: string | null; since: Instant }>;
|
|
148
|
-
queues: Record<string, { text: string; at: Instant; context_from?: string[] | null; skills?: string[]; job_id?: string }[]>;
|
|
149
|
-
turns: Record<string, { started_at: Instant; buffer: string }>;
|
|
148
|
+
queues: Record<string, { text: string; at: Instant; context_from?: string[] | null; skills?: string[]; job_id?: string; webhook_input_id?: string }[]>;
|
|
149
|
+
turns: Record<string, { started_at: Instant; buffer: string; webhook_input_id?: string }>;
|
|
150
150
|
starting: Record<string, unknown>;
|
|
151
151
|
/** The last worker event per lease (§4.3's inactivity limit). */
|
|
152
152
|
activity: Record<string, Instant>;
|
|
@@ -166,6 +166,7 @@ export type WorkerEvent =
|
|
|
166
166
|
| { kind: 'text'; delta: string }
|
|
167
167
|
/** Any other runtime event (a tool call, reasoning, a turn start): the worker is alive. */
|
|
168
168
|
| { kind: 'activity' }
|
|
169
|
+
| { kind: 'steer_refused'; text: string; webhook_input_id?: string | null }
|
|
169
170
|
| { kind: 'final'; text: string | null; outcome?: 'completed' | 'error'; message?: string | null }
|
|
170
171
|
| { kind: 'request'; id: string; subject: string; options: RequestOption[] }
|
|
171
172
|
| { kind: 'ended'; reason: string }
|
|
@@ -187,7 +188,8 @@ export type OrchestratorEvent =
|
|
|
187
188
|
| { type: 'worker'; profile: ProfileName; keyString: string; runtime_id: string | null; ev: WorkerEvent; at: Instant }
|
|
188
189
|
| { type: 'adapter'; platform: Platform; state: 'up' | 'down'; error: string | null; at: Instant }
|
|
189
190
|
| { type: 'operator'; verb: string; profile: ProfileName; args: Record<string, unknown>; at: Instant }
|
|
190
|
-
| { type: 'webhook'; name: string; payload: JsonValue; verified: boolean; at: Instant; event: string | null; delivery_id: string | null }
|
|
191
|
+
| { type: 'webhook'; name: string; payload: JsonValue; verified: boolean; at: Instant; event: string | null; delivery_id: string | null; admission_id?: string }
|
|
192
|
+
| { type: 'webhook_refused'; name: string; delivery_id: string | null; admission_id: string; profile?: ProfileName; keyString?: string; handed: boolean; stopping: boolean; at: Instant }
|
|
191
193
|
| { type: 'restart'; at: Instant }
|
|
192
194
|
| { type: 'stopping'; at: Instant };
|
|
193
195
|
|
|
@@ -197,7 +199,8 @@ export type Effect =
|
|
|
197
199
|
| { type: 'execute_job'; profile: ProfileName; job_id: string; fire_id: string }
|
|
198
200
|
| { type: 'runtime_start'; profile: ProfileName; keyString: string; worker: WorkerSpec; prompt: string | null }
|
|
199
201
|
| { type: 'runtime_close'; profile: ProfileName; keyString: string; lease: RuntimeState['leases'][string] }
|
|
200
|
-
| { type: 'send_input'; profile: ProfileName; keyString: string; text: string; context_from?: string[] | null; skills?: string[]; job_id?: string }
|
|
202
|
+
| { type: 'send_input'; profile: ProfileName; keyString: string; text: string; context_from?: string[] | null; skills?: string[]; job_id?: string; webhook_input_id?: string }
|
|
203
|
+
| { type: 'webhook_input'; admission_id: string; profile: ProfileName; keyString: string; refusal?: string }
|
|
201
204
|
| { type: 'respond'; profile: ProfileName; keyString: string; request_id: string; response: JsonValue }
|
|
202
205
|
| { type: 'deliver'; profile: ProfileName; obligation_id: string }
|
|
203
206
|
| { type: 'write_local'; profile: ProfileName; path: RelPath; text: string }
|
|
@@ -288,6 +291,7 @@ export class ChatSdkAdapter implements ChannelAdapter {
|
|
|
288
291
|
/** The one door from the SDK into the loop: `(thread, message)` -> an `inbound`. */
|
|
289
292
|
handle(thread: unknown, message: unknown): unknown;
|
|
290
293
|
send(target: SurfaceKey, content: { text: string; at?: Instant; reply_to?: string }): Promise<{ ok: true; message_id?: string }>;
|
|
294
|
+
/** Joins SDK platform/state disconnect and gateway work once; rejects with retained cleanup causes. A failed owner retains its token claim. */
|
|
291
295
|
close(): Promise<void>;
|
|
292
296
|
}
|
|
293
297
|
|
|
@@ -369,6 +373,7 @@ export class Orchestrator {
|
|
|
369
373
|
readonly effectLog: Effect[];
|
|
370
374
|
readonly webhookPort: number | null;
|
|
371
375
|
start(): Promise<void>;
|
|
376
|
+
/** Seals webhook ingress before waits; joins admitted processing and retains cleanup failures. */
|
|
372
377
|
stop(): Promise<void>;
|
|
373
378
|
tick(at?: Instant): Promise<Effect[]>;
|
|
374
379
|
drainHosted(): Promise<void>;
|
package/installed-apps.mjs
CHANGED
|
@@ -9,11 +9,12 @@ import { delimiter, isAbsolute, join } from 'node:path';
|
|
|
9
9
|
export const APPS_DIR = 'apps';
|
|
10
10
|
/** The apps this orchestrator can host, by name: what each installs and which adapter it attaches. */
|
|
11
11
|
export const INSTALLABLE_APPS = Object.freeze({
|
|
12
|
-
'arcs-rooms': { side: 'rh2', module: true, describe: 'arcs-rooms installed module' },
|
|
13
12
|
'orchestration-rh2': { side: 'rh2', module: true, describe: 'orchestration-rh2 installed module' },
|
|
14
13
|
'room-chat': { side: 'rh2', adapter: 'rh2', describe: 'Room chat → orchestrator: a person\'s line in an RH2 Room reaches the agent seated there' },
|
|
15
14
|
// An agent the RH2 organization declares is one participant there: its chat and its call are its own agent principal,
|
|
16
|
-
// never an automation beside it. One record per agent, bound to its Teams id (`
|
|
15
|
+
// never an automation beside it. One record per agent per Workplace, bound to its Teams id (`agentId`) and that
|
|
16
|
+
// Workplace's origin: one installed by hand (`rh2-agent.<profile>`), or one the Agents → Room agents integration
|
|
17
|
+
// provisioned (`rh2-agent.<agent id>@<host>`, sdk/teams/workplace/agent-credentials.mjs).
|
|
17
18
|
'rh2-agent': { side: 'rh2', adapter: 'rh2', perProfile: true, describe: 'An agent the RH2 organization declares, in its Rooms as its own principal' },
|
|
18
19
|
});
|
|
19
20
|
|
|
@@ -48,19 +49,38 @@ export async function installAgent({ root, origin, organization, principal, toke
|
|
|
48
49
|
return { ...record, name: `rh2-agent.${profile}`, token: undefined };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (typeof agentId !== 'string' || !/^agent_[A-Za-z0-9_-]+$/.test(agentId)) return null;
|
|
52
|
+
/** Every rh2-agent record that is an identity of `agentId` (an agent's Teams id), one per Workplace: found by that id
|
|
53
|
+
* alone, never by a name or profile another agent may share (a later generation of a card's agent, another home's root
|
|
54
|
+
* agent named after the same folder). A file name is a label. */
|
|
55
|
+
export function installedAgentRecords(root, agentId) {
|
|
56
|
+
if (typeof agentId !== 'string' || !/^agent_[A-Za-z0-9_-]+$/.test(agentId)) return [];
|
|
57
57
|
let records;
|
|
58
|
-
try { records = installedApps(root); } catch { return
|
|
58
|
+
try { records = installedApps(root); } catch { return []; }
|
|
59
59
|
// the origin install accepted (https, or a loopback for a local RH2), held again wherever the record is used
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
return records.filter((record) => record.app === 'rh2-agent' && record.agentId === agentId && record.token && rh2Origin(record.origin))
|
|
61
|
+
.sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0));
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** The rh2-agent record that is the identity of `agentId` on the Workplace at `origin`, or null; with no origin, its one
|
|
65
|
+
* record, else the one installed by hand (`apps install rh2-agent`), the identity its channel and its sessions' first
|
|
66
|
+
* Workplace have always been. Only one record may answer: two for one Workplace is none. */
|
|
67
|
+
export function installedAgentRecord(root, agentId, origin = null) {
|
|
68
|
+
const own = installedAgentRecords(root, agentId);
|
|
69
|
+
if (origin !== null) {
|
|
70
|
+
const at = own.filter((record) => record.origin === originOf(origin));
|
|
71
|
+
return at.length === 1 ? at[0] : null;
|
|
72
|
+
}
|
|
73
|
+
if (own.length === 1) return own[0];
|
|
74
|
+
const byHand = own.filter((record) => !record.provisionedBy);
|
|
75
|
+
return byHand.length === 1 ? byHand[0] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const originOf = (value) => { try { return new URL(value).origin; } catch { return null; } };
|
|
79
|
+
|
|
80
|
+
// A record the Agents → Room agents integration provisioned is written by that service (sdk/teams/workplace/
|
|
81
|
+
// agent-credentials.mjs) into this same folder, in this same shape (`provisionedBy` names it), and read here like any.
|
|
82
|
+
|
|
83
|
+
|
|
64
84
|
/** Whether `origin` is one an RH2 app may be installed for: https, or a loopback for a local RH2. */
|
|
65
85
|
function rh2Origin(origin) {
|
|
66
86
|
return /^https:\/\/|^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(String(origin ?? ''));
|
|
@@ -75,9 +95,10 @@ export function rh2Program() {
|
|
|
75
95
|
return folders.map((folder) => join(folder, 'rh2')).find((file) => existsSync(file)) ?? null;
|
|
76
96
|
}
|
|
77
97
|
|
|
78
|
-
/** The environment the `rh2` CLI reads to act as an installed agent: its RH2 origin, its organization and its token
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
/** The environment the `rh2` CLI reads to act as an installed agent: its RH2 origin, its organization and its token,
|
|
99
|
+
* and the home whose records hold its credentials on its other Workplaces (`RH2_AGENT_HOMES`, read per call). */
|
|
100
|
+
export function rh2AgentEnv(record, root = null) {
|
|
101
|
+
return { RH2_BASE_URL: record.origin, RH2_ORGANIZATION: record.organizationId, RH2_SESSION_TOKEN: record.token, ...(root ? { RH2_AGENT_HOMES: root } : {}) };
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
/** Exchange an RH2 install code for the installation's token and keep the record. */
|
package/kanban-worker.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
12
12
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
13
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
14
14
|
import { load } from './orchestration.mjs';
|
|
15
|
-
import { INSTRUCTION_ARGUMENTS, activationCwd, activationEnv, declareWorkerHome,
|
|
15
|
+
import { INSTRUCTION_ARGUMENTS, activationCwd, activationEnv, declareWorkerHome, modeArguments, approvalPolicy, approvalsReviewer, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
|
|
16
16
|
import { workerRoute } from './worker-route.mjs';
|
|
17
17
|
import { KANBAN_ENV, untrustedServerVerdict, hermesApproval, hermesInstall, memorySnapshot } from './hermes-cli.mjs';
|
|
18
18
|
import { pinnedSkills, taskProtocol } from './board/worker-prompt.mjs';
|
|
@@ -95,13 +95,13 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
|
|
|
95
95
|
const reasoning = args.reasoning && harness === 'codex' ? ['-c', `model_reasoning_effort=${JSON.stringify(args.reasoning)}`] : [];
|
|
96
96
|
const launch = {
|
|
97
97
|
program: base.program,
|
|
98
|
-
arguments: boardGuardArguments(harness, [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...
|
|
98
|
+
arguments: boardGuardArguments(harness, [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...modeArguments(profile, harness), ...limitArguments(profile, worker, (level, line) => say(line))], { enabled: ['claude-code','codex'].includes(harness), cwd }),
|
|
99
99
|
env: { ...(base.env ?? {}), ...runEnv },
|
|
100
100
|
};
|
|
101
101
|
declareWorkerHome({ harness, env: launch.env, cwd, log: (_level, line) => say(`hermes-worker: ${line}`) });
|
|
102
102
|
const mount = mountOrchestratorMcp({ root, profile, harness, surfaceKey, launch, vault, task });
|
|
103
|
-
const policy = approvalPolicy(profile, harness);
|
|
104
|
-
const runtime = await client.startManagedRuntime({ harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}) });
|
|
103
|
+
const policy = approvalPolicy(profile, harness), reviewer = approvalsReviewer(profile, harness);
|
|
104
|
+
const runtime = await client.startManagedRuntime({ harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}), ...(reviewer ? { approvals_reviewer: reviewer } : {}) });
|
|
105
105
|
try { await recordSession(root, task, harness, runtime.handle.runtime_id); } catch (error) { say(`hermes-worker: recording the session on its run: ${error.message}`); }
|
|
106
106
|
let text = '', last = '';
|
|
107
107
|
// `agent.max_turns` for a worker that takes no cap at launch, held as the daemon holds it (loop.mjs `holdTurnLimit`)
|
package/lease.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function pidIsLive(pid) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/** Machine/boot observation only. Neither this nor PID liveness grants stop authority. */
|
|
31
31
|
export function leaseIsHere(lease) {
|
|
32
32
|
const here = thisHost();
|
|
33
33
|
if (lease?.host && lease.host !== here.host) return false;
|