@nettee/beacon 0.5.0 → 0.6.0
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/deploy/io.nettee.beacon.plist.example +12 -0
- package/dist/daemon/dashboard/profiles.js +8 -0
- package/dist/daemon/domain/types.d.ts +10 -0
- package/dist/daemon/outcome/server.d.ts +2 -0
- package/dist/daemon/outcome/server.js +13 -3
- package/dist/daemon/outcome/submit.d.ts +8 -1
- package/dist/daemon/outcome/submit.js +33 -2
- package/dist/daemon/run/create-pi-orchestrator.d.ts +6 -1
- package/dist/daemon/run/create-pi-orchestrator.js +19 -0
- package/dist/daemon/run/orchestrator.d.ts +19 -0
- package/dist/daemon/run/orchestrator.js +185 -24
- package/dist/daemon/runtime/pi-rpc.d.ts +2 -0
- package/dist/daemon/runtime/pi-rpc.js +9 -0
- package/dist/daemon/runtime/pi-worker-lifecycle.d.ts +92 -0
- package/dist/daemon/runtime/pi-worker-lifecycle.js +208 -0
- package/dist/daemon/runtime/pi-worker-main.d.ts +1 -0
- package/dist/daemon/runtime/pi-worker-main.js +223 -0
- package/dist/daemon/runtime/supervisor-lease.d.ts +15 -0
- package/dist/daemon/runtime/supervisor-lease.js +63 -0
- package/dist/daemon/service.js +7 -1
- package/dist/daemon/state/trigger-store.js +16 -0
- package/dist/web/assets/{index-DSJwEbKP.js → index-CrbxPxbI.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DEFAULT_ORPHAN_AFTER_MS, isSupervisorLeaseFresh, } from "./supervisor-lease.js";
|
|
6
|
+
const REQUEST_FILE = "request.json";
|
|
7
|
+
const COMPLETION_FILE = "completion.json";
|
|
8
|
+
const HEARTBEAT_FILE = "worker.heartbeat";
|
|
9
|
+
const PID_FILE = "worker.pid";
|
|
10
|
+
export function lifecyclePaths(lifecycleDirectory) {
|
|
11
|
+
return {
|
|
12
|
+
request: join(lifecycleDirectory, REQUEST_FILE),
|
|
13
|
+
completion: join(lifecycleDirectory, COMPLETION_FILE),
|
|
14
|
+
heartbeat: join(lifecycleDirectory, HEARTBEAT_FILE),
|
|
15
|
+
pid: join(lifecycleDirectory, PID_FILE),
|
|
16
|
+
outcomeSocket: join(lifecycleDirectory, "outcomes.sock"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export async function prepareLifecycleDirectory(lifecycleDirectory) {
|
|
20
|
+
if (!isAbsolute(lifecycleDirectory)) {
|
|
21
|
+
throw new Error("Pi worker lifecycle directory must be absolute");
|
|
22
|
+
}
|
|
23
|
+
await mkdir(lifecycleDirectory, { recursive: true, mode: 0o700 });
|
|
24
|
+
await chmod(lifecycleDirectory, 0o700);
|
|
25
|
+
const paths = lifecyclePaths(lifecycleDirectory);
|
|
26
|
+
await rm(paths.completion, { force: true });
|
|
27
|
+
await rm(paths.heartbeat, { force: true });
|
|
28
|
+
await rm(paths.pid, { force: true });
|
|
29
|
+
await rm(paths.outcomeSocket, { force: true });
|
|
30
|
+
}
|
|
31
|
+
export async function writeWorkerRequest(lifecycleDirectory, request) {
|
|
32
|
+
await prepareLifecycleDirectory(lifecycleDirectory);
|
|
33
|
+
const temporary = `${lifecyclePaths(lifecycleDirectory).request}.tmp`;
|
|
34
|
+
await writeFile(temporary, `${JSON.stringify(request, null, 2)}\n`, {
|
|
35
|
+
mode: 0o600,
|
|
36
|
+
});
|
|
37
|
+
await rename(temporary, lifecyclePaths(lifecycleDirectory).request);
|
|
38
|
+
}
|
|
39
|
+
export async function readWorkerRequest(lifecycleDirectory) {
|
|
40
|
+
const raw = JSON.parse(await readFile(lifecyclePaths(lifecycleDirectory).request, "utf8"));
|
|
41
|
+
if (!raw.beaconCliPath || !raw.runToken || !raw.supervisorLeasePath) {
|
|
42
|
+
throw new Error("Pi worker request is missing required fields");
|
|
43
|
+
}
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
export async function writeWorkerHeartbeat(lifecycleDirectory) {
|
|
47
|
+
const temporary = `${lifecyclePaths(lifecycleDirectory).heartbeat}.tmp`;
|
|
48
|
+
await writeFile(temporary, `${JSON.stringify({ pid: process.pid, updatedAt: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
49
|
+
await rename(temporary, lifecyclePaths(lifecycleDirectory).heartbeat);
|
|
50
|
+
}
|
|
51
|
+
export async function writeWorkerPid(lifecycleDirectory, pid) {
|
|
52
|
+
await writeFile(lifecyclePaths(lifecycleDirectory).pid, `${pid}\n`, {
|
|
53
|
+
mode: 0o600,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function writeWorkerCompletion(lifecycleDirectory, completion) {
|
|
57
|
+
const temporary = `${lifecyclePaths(lifecycleDirectory).completion}.tmp`;
|
|
58
|
+
await writeFile(temporary, `${JSON.stringify(completion, null, 2)}\n`, {
|
|
59
|
+
mode: 0o600,
|
|
60
|
+
});
|
|
61
|
+
await rename(temporary, lifecyclePaths(lifecycleDirectory).completion);
|
|
62
|
+
}
|
|
63
|
+
export async function readWorkerCompletion(lifecycleDirectory) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(await readFile(lifecyclePaths(lifecycleDirectory).completion, "utf8"));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function readWorkerHeartbeat(lifecycleDirectory) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(await readFile(lifecyclePaths(lifecycleDirectory).heartbeat, "utf8"));
|
|
74
|
+
if (typeof raw !== "object" ||
|
|
75
|
+
raw === null ||
|
|
76
|
+
!("pid" in raw) ||
|
|
77
|
+
!("updatedAt" in raw) ||
|
|
78
|
+
typeof raw.pid !== "number" ||
|
|
79
|
+
typeof raw.updatedAt !== "string") {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return { pid: raw.pid, updatedAt: raw.updatedAt };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function readWorkerPid(lifecycleDirectory) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = (await readFile(lifecyclePaths(lifecycleDirectory).pid, "utf8")).trim();
|
|
91
|
+
const pid = Number(raw);
|
|
92
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function isProcessAlive(pid) {
|
|
99
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
100
|
+
return false;
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function isWorkerAdoptable(handle, nowMs = Date.now(), heartbeatMaxAgeMs = 60_000) {
|
|
110
|
+
if (!isProcessAlive(handle.workerPid))
|
|
111
|
+
return false;
|
|
112
|
+
const completion = await readWorkerCompletion(handle.lifecycleDirectory);
|
|
113
|
+
if (completion)
|
|
114
|
+
return true;
|
|
115
|
+
const heartbeat = await readWorkerHeartbeat(handle.lifecycleDirectory);
|
|
116
|
+
if (!heartbeat)
|
|
117
|
+
return false;
|
|
118
|
+
const updatedAt = Date.parse(heartbeat.updatedAt);
|
|
119
|
+
if (!Number.isFinite(updatedAt))
|
|
120
|
+
return false;
|
|
121
|
+
return nowMs - updatedAt <= heartbeatMaxAgeMs;
|
|
122
|
+
}
|
|
123
|
+
export async function waitForWorkerCompletion(lifecycleDirectory, options = {}) {
|
|
124
|
+
const pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
125
|
+
for (;;) {
|
|
126
|
+
if (options.abortSignal?.aborted) {
|
|
127
|
+
throw new Error("Waiting for Pi worker completion was aborted");
|
|
128
|
+
}
|
|
129
|
+
const completion = await readWorkerCompletion(lifecycleDirectory);
|
|
130
|
+
if (completion)
|
|
131
|
+
return completion;
|
|
132
|
+
await new Promise((resolve) => {
|
|
133
|
+
const timer = setTimeout(resolve, pollIntervalMs);
|
|
134
|
+
options.abortSignal?.addEventListener("abort", () => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
resolve();
|
|
137
|
+
}, { once: true });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function resolvePiWorkerMainPath() {
|
|
142
|
+
return fileURLToPath(new URL(import.meta.url.endsWith(".ts")
|
|
143
|
+
? "../../dist/runtime/pi-worker-main.js"
|
|
144
|
+
: "./pi-worker-main.js", import.meta.url));
|
|
145
|
+
}
|
|
146
|
+
export async function spawnDetachedPiWorker(lifecycleDirectory, request, options) {
|
|
147
|
+
const orphanAfterMs = options.orphanAfterMs ?? DEFAULT_ORPHAN_AFTER_MS;
|
|
148
|
+
const workerRequest = {
|
|
149
|
+
...request,
|
|
150
|
+
beaconCliPath: options.beaconCliPath,
|
|
151
|
+
runToken: options.runToken,
|
|
152
|
+
supervisorLeasePath: options.supervisorLeasePath,
|
|
153
|
+
orphanAfterMs,
|
|
154
|
+
executable: options.executable,
|
|
155
|
+
timeoutMs: options.timeoutMs,
|
|
156
|
+
terminateGraceMs: options.terminateGraceMs,
|
|
157
|
+
maxFrameBytes: options.maxFrameBytes,
|
|
158
|
+
environment: options.environment,
|
|
159
|
+
runtimeEnvironment: options.runtimeEnvironment,
|
|
160
|
+
};
|
|
161
|
+
await writeWorkerRequest(lifecycleDirectory, workerRequest);
|
|
162
|
+
const workerMainPath = options.workerMainPath ?? resolvePiWorkerMainPath();
|
|
163
|
+
try {
|
|
164
|
+
await access(workerMainPath);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Error(`Pi worker entry is missing (build the daemon first): ${workerMainPath}`, { cause: error });
|
|
168
|
+
}
|
|
169
|
+
const child = spawn(process.execPath, [workerMainPath, lifecycleDirectory], {
|
|
170
|
+
detached: true,
|
|
171
|
+
stdio: "ignore",
|
|
172
|
+
env: {
|
|
173
|
+
PATH: process.env.PATH,
|
|
174
|
+
HOME: process.env.HOME,
|
|
175
|
+
USER: process.env.USER,
|
|
176
|
+
TMPDIR: process.env.TMPDIR,
|
|
177
|
+
LANG: process.env.LANG,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (child.pid === undefined) {
|
|
181
|
+
throw new Error("Failed to spawn detached Pi worker (no pid)");
|
|
182
|
+
}
|
|
183
|
+
child.unref();
|
|
184
|
+
await writeWorkerPid(lifecycleDirectory, child.pid);
|
|
185
|
+
return {
|
|
186
|
+
workerPid: child.pid,
|
|
187
|
+
lifecycleDirectory,
|
|
188
|
+
runToken: options.runToken,
|
|
189
|
+
supervisorLeasePath: options.supervisorLeasePath,
|
|
190
|
+
orphanAfterMs,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Stop a still-living worker (orphan / abandon). Best-effort. */
|
|
194
|
+
export function signalWorker(pid, signal = "SIGTERM") {
|
|
195
|
+
if (!isProcessAlive(pid))
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
process.kill(pid, signal);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// already gone
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function assertSupervisorOrThrow(supervisorLeasePath, orphanAfterMs) {
|
|
205
|
+
if (!(await isSupervisorLeaseFresh(supervisorLeasePath, orphanAfterMs))) {
|
|
206
|
+
throw new Error(`No Beacon supervisor lease for ${orphanAfterMs}ms; abandoning orphan Pi worker`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { chmod, mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { completeOutcome, mergeOutcome, parseOutcomePatch, } from "../outcome/content.js";
|
|
4
|
+
import { PiRuntimeError, runPiAgent } from "./pi-rpc.js";
|
|
5
|
+
import { assertSupervisorOrThrow, lifecyclePaths, readWorkerRequest, writeWorkerCompletion, writeWorkerHeartbeat, } from "./pi-worker-lifecycle.js";
|
|
6
|
+
import { DEFAULT_ORPHAN_AFTER_MS } from "./supervisor-lease.js";
|
|
7
|
+
function respond(socket, response) {
|
|
8
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
9
|
+
}
|
|
10
|
+
async function startWorkerOutcomeServer(socketPath, runToken, maxRequestBytes = 1024 * 1024) {
|
|
11
|
+
await rm(socketPath, { force: true });
|
|
12
|
+
const record = {};
|
|
13
|
+
const server = createServer((socket) => {
|
|
14
|
+
socket.setEncoding("utf8");
|
|
15
|
+
let buffer = "";
|
|
16
|
+
let finished = false;
|
|
17
|
+
socket.on("data", (chunk) => {
|
|
18
|
+
if (finished)
|
|
19
|
+
return;
|
|
20
|
+
buffer += chunk;
|
|
21
|
+
if (Buffer.byteLength(buffer, "utf8") > maxRequestBytes) {
|
|
22
|
+
finished = true;
|
|
23
|
+
respond(socket, {
|
|
24
|
+
ok: false,
|
|
25
|
+
error: "Outcome submission request is too large",
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const newline = buffer.indexOf("\n");
|
|
30
|
+
if (newline < 0)
|
|
31
|
+
return;
|
|
32
|
+
finished = true;
|
|
33
|
+
let request;
|
|
34
|
+
try {
|
|
35
|
+
request = JSON.parse(buffer.slice(0, newline));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
respond(socket, { ok: false, error: "invalid JSON request" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (typeof request !== "object" ||
|
|
42
|
+
request === null ||
|
|
43
|
+
!("runToken" in request) ||
|
|
44
|
+
!("outcome" in request) ||
|
|
45
|
+
typeof request.runToken !== "string") {
|
|
46
|
+
respond(socket, {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: "runToken and outcome are required",
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (request.runToken !== runToken) {
|
|
53
|
+
respond(socket, { ok: false, error: "unknown Run Capability" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let patch;
|
|
57
|
+
try {
|
|
58
|
+
patch = parseOutcomePatch(request.outcome);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
respond(socket, {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `invalid Final Outcome: ${error instanceof Error ? error.message : String(error)}`,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const previous = record.outcome;
|
|
69
|
+
record.outcome = mergeOutcome(record.outcome, patch);
|
|
70
|
+
if (patch.feedback && !previous?.feedback) {
|
|
71
|
+
record.feedbackSubmittedAt = new Date().toISOString();
|
|
72
|
+
}
|
|
73
|
+
if (patch.observabilityFeedback && !previous?.observabilityFeedback) {
|
|
74
|
+
record.observabilityFeedbackSubmittedAt = new Date().toISOString();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
respond(socket, {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: error instanceof Error ? error.message : String(error),
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
respond(socket, { ok: true });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
server.once("error", reject);
|
|
89
|
+
server.listen(socketPath, () => {
|
|
90
|
+
server.off("error", reject);
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
await chmod(socketPath, 0o600);
|
|
95
|
+
return {
|
|
96
|
+
take() {
|
|
97
|
+
return completeOutcome(record.outcome ?? {});
|
|
98
|
+
},
|
|
99
|
+
takeFeedback() {
|
|
100
|
+
const items = record.outcome?.feedback?.items;
|
|
101
|
+
if (!items || record.feedbackSubmittedAt === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
return { items, submittedAt: record.feedbackSubmittedAt };
|
|
104
|
+
},
|
|
105
|
+
takeObservabilityFeedback() {
|
|
106
|
+
const items = record.outcome?.observabilityFeedback?.items;
|
|
107
|
+
if (!items || record.observabilityFeedbackSubmittedAt === undefined) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return { items, submittedAt: record.observabilityFeedbackSubmittedAt };
|
|
111
|
+
},
|
|
112
|
+
hasReply() {
|
|
113
|
+
return record.outcome?.reply !== undefined;
|
|
114
|
+
},
|
|
115
|
+
async close() {
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
118
|
+
});
|
|
119
|
+
await rm(socketPath, { force: true });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function runWorker(lifecycleDirectory) {
|
|
124
|
+
const request = await readWorkerRequest(lifecycleDirectory);
|
|
125
|
+
const orphanAfterMs = request.orphanAfterMs ?? DEFAULT_ORPHAN_AFTER_MS;
|
|
126
|
+
const paths = lifecyclePaths(lifecycleDirectory);
|
|
127
|
+
await mkdir(lifecycleDirectory, { recursive: true, mode: 0o700 });
|
|
128
|
+
const abort = new AbortController();
|
|
129
|
+
const heartbeat = setInterval(() => {
|
|
130
|
+
void writeWorkerHeartbeat(lifecycleDirectory).catch(() => undefined);
|
|
131
|
+
void assertSupervisorOrThrow(request.supervisorLeasePath, orphanAfterMs)
|
|
132
|
+
.then(() => undefined)
|
|
133
|
+
.catch(() => {
|
|
134
|
+
if (!abort.signal.aborted)
|
|
135
|
+
abort.abort();
|
|
136
|
+
});
|
|
137
|
+
}, 5_000);
|
|
138
|
+
heartbeat.unref?.();
|
|
139
|
+
await writeWorkerHeartbeat(lifecycleDirectory);
|
|
140
|
+
const outcomes = await startWorkerOutcomeServer(paths.outcomeSocket, request.runToken);
|
|
141
|
+
const finish = async (completion) => {
|
|
142
|
+
clearInterval(heartbeat);
|
|
143
|
+
await writeWorkerCompletion(lifecycleDirectory, completion);
|
|
144
|
+
await outcomes.close().catch(() => undefined);
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
await assertSupervisorOrThrow(request.supervisorLeasePath, orphanAfterMs);
|
|
148
|
+
const result = await runPiAgent({
|
|
149
|
+
prompt: request.prompt,
|
|
150
|
+
playbook: request.playbook,
|
|
151
|
+
provider: request.provider,
|
|
152
|
+
model: request.model,
|
|
153
|
+
systemPrompt: request.systemPrompt,
|
|
154
|
+
session: request.session,
|
|
155
|
+
skills: request.skills,
|
|
156
|
+
outcome: {
|
|
157
|
+
socketPath: paths.outcomeSocket,
|
|
158
|
+
runToken: request.runToken,
|
|
159
|
+
cliPath: request.beaconCliPath,
|
|
160
|
+
},
|
|
161
|
+
}, {
|
|
162
|
+
executable: request.executable,
|
|
163
|
+
timeoutMs: request.timeoutMs,
|
|
164
|
+
terminateGraceMs: request.terminateGraceMs,
|
|
165
|
+
maxFrameBytes: request.maxFrameBytes,
|
|
166
|
+
environment: request.environment,
|
|
167
|
+
runtimeEnvironment: request.runtimeEnvironment,
|
|
168
|
+
signal: abort.signal,
|
|
169
|
+
});
|
|
170
|
+
await finish({
|
|
171
|
+
ok: true,
|
|
172
|
+
result,
|
|
173
|
+
outcome: outcomes.take(),
|
|
174
|
+
feedback: outcomes.takeFeedback(),
|
|
175
|
+
observabilityFeedback: outcomes.takeObservabilityFeedback(),
|
|
176
|
+
hasReply: outcomes.hasReply(),
|
|
177
|
+
finishedAt: new Date().toISOString(),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const orphaned = abort.signal.aborted;
|
|
182
|
+
const code = orphaned
|
|
183
|
+
? "orphan_abandoned"
|
|
184
|
+
: error instanceof PiRuntimeError
|
|
185
|
+
? error.code
|
|
186
|
+
: "worker_failed";
|
|
187
|
+
const message = orphaned
|
|
188
|
+
? "Pi worker stopped after Beacon supervisor lease expired (orphan safety)"
|
|
189
|
+
: error instanceof Error
|
|
190
|
+
? error.message
|
|
191
|
+
: String(error);
|
|
192
|
+
await finish({
|
|
193
|
+
ok: false,
|
|
194
|
+
error: { code, message },
|
|
195
|
+
hasReply: outcomes.hasReply(),
|
|
196
|
+
feedback: outcomes.takeFeedback(),
|
|
197
|
+
observabilityFeedback: outcomes.takeObservabilityFeedback(),
|
|
198
|
+
finishedAt: new Date().toISOString(),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const lifecycleDirectory = process.argv[2];
|
|
203
|
+
if (!lifecycleDirectory) {
|
|
204
|
+
console.error("Usage: node pi-worker-main.js <lifecycle-directory>");
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
runWorker(lifecycleDirectory).catch(async (error) => {
|
|
209
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
210
|
+
try {
|
|
211
|
+
await writeWorkerCompletion(lifecycleDirectory, {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: { code: "worker_failed", message },
|
|
214
|
+
hasReply: false,
|
|
215
|
+
finishedAt: new Date().toISOString(),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
console.error(`[beacon-pi-worker] fatal: ${message}`);
|
|
220
|
+
}
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SupervisorLease = {
|
|
2
|
+
pid: number;
|
|
3
|
+
updatedAt: string;
|
|
4
|
+
};
|
|
5
|
+
export declare const DEFAULT_ORPHAN_AFTER_MS = 120000;
|
|
6
|
+
export declare const DEFAULT_LEASE_INTERVAL_MS = 10000;
|
|
7
|
+
export declare function writeSupervisorLease(path: string): Promise<void>;
|
|
8
|
+
export declare function readSupervisorLease(path: string): Promise<SupervisorLease | undefined>;
|
|
9
|
+
/** True when a supervisor has refreshed the lease within orphanAfterMs. */
|
|
10
|
+
export declare function isSupervisorLeaseFresh(path: string, orphanAfterMs: number, nowMs?: number): Promise<boolean>;
|
|
11
|
+
export type SupervisorLeaseHandle = {
|
|
12
|
+
stop(): void;
|
|
13
|
+
};
|
|
14
|
+
/** Refresh the lease until stop(). Writes once immediately. */
|
|
15
|
+
export declare function startSupervisorLeaseWriter(path: string, intervalMs?: number): SupervisorLeaseHandle;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
export const DEFAULT_ORPHAN_AFTER_MS = 120_000;
|
|
4
|
+
export const DEFAULT_LEASE_INTERVAL_MS = 10_000;
|
|
5
|
+
export async function writeSupervisorLease(path) {
|
|
6
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
7
|
+
const lease = {
|
|
8
|
+
pid: process.pid,
|
|
9
|
+
updatedAt: new Date().toISOString(),
|
|
10
|
+
};
|
|
11
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
12
|
+
await writeFile(temporary, `${JSON.stringify(lease)}\n`, { mode: 0o600 });
|
|
13
|
+
await rename(temporary, path);
|
|
14
|
+
}
|
|
15
|
+
export async function readSupervisorLease(path) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
18
|
+
if (typeof raw !== "object" ||
|
|
19
|
+
raw === null ||
|
|
20
|
+
!("pid" in raw) ||
|
|
21
|
+
!("updatedAt" in raw) ||
|
|
22
|
+
typeof raw.pid !== "number" ||
|
|
23
|
+
typeof raw.updatedAt !== "string") {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return { pid: raw.pid, updatedAt: raw.updatedAt };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** True when a supervisor has refreshed the lease within orphanAfterMs. */
|
|
33
|
+
export async function isSupervisorLeaseFresh(path, orphanAfterMs, nowMs = Date.now()) {
|
|
34
|
+
const lease = await readSupervisorLease(path);
|
|
35
|
+
if (!lease)
|
|
36
|
+
return false;
|
|
37
|
+
const updatedAt = Date.parse(lease.updatedAt);
|
|
38
|
+
if (!Number.isFinite(updatedAt))
|
|
39
|
+
return false;
|
|
40
|
+
return nowMs - updatedAt <= orphanAfterMs;
|
|
41
|
+
}
|
|
42
|
+
/** Refresh the lease until stop(). Writes once immediately. */
|
|
43
|
+
export function startSupervisorLeaseWriter(path, intervalMs = DEFAULT_LEASE_INTERVAL_MS) {
|
|
44
|
+
let stopped = false;
|
|
45
|
+
let timer;
|
|
46
|
+
const tick = () => {
|
|
47
|
+
void writeSupervisorLease(path).catch((error) => {
|
|
48
|
+
console.error(`[beacon] failed to refresh supervisor lease: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
tick();
|
|
52
|
+
timer = setInterval(tick, intervalMs);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
return {
|
|
55
|
+
stop() {
|
|
56
|
+
if (stopped)
|
|
57
|
+
return;
|
|
58
|
+
stopped = true;
|
|
59
|
+
if (timer)
|
|
60
|
+
clearInterval(timer);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/daemon/service.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createFeishuMessagePipeline } from "./feishu/message-pipeline.js";
|
|
|
14
14
|
import { startOutcomeServer } from "./outcome/server.js";
|
|
15
15
|
import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
|
|
16
16
|
import { createRunQueue } from "./run/queue.js";
|
|
17
|
+
import { startSupervisorLeaseWriter } from "./runtime/supervisor-lease.js";
|
|
17
18
|
import { ScheduleCursorStore } from "./schedule/cursor-store.js";
|
|
18
19
|
import { ScheduleLoop } from "./schedule/loop.js";
|
|
19
20
|
import { ScheduleReconciler } from "./schedule/reconciler.js";
|
|
@@ -44,7 +45,11 @@ export async function runBeacon(configPath, options) {
|
|
|
44
45
|
const eventProducers = global.events
|
|
45
46
|
? await loadEventCredentials(global.events.credentialsPath)
|
|
46
47
|
: [];
|
|
47
|
-
const
|
|
48
|
+
const supervisorLeasePath = join(global.homeDirectory, "state", "supervisor.lease");
|
|
49
|
+
const supervisorLease = startSupervisorLeaseWriter(supervisorLeasePath);
|
|
50
|
+
const outcomes = await startOutcomeServer({
|
|
51
|
+
directory: join(global.homeDirectory, "state", "outcomes"),
|
|
52
|
+
});
|
|
48
53
|
const queue = createRunQueue(global.runs, profiles);
|
|
49
54
|
let dashboard;
|
|
50
55
|
try {
|
|
@@ -165,6 +170,7 @@ export async function runBeacon(configPath, options) {
|
|
|
165
170
|
dashboard?.close() ?? Promise.resolve(),
|
|
166
171
|
outcomes.close(),
|
|
167
172
|
]);
|
|
173
|
+
supervisorLease.stop();
|
|
168
174
|
for (const result of [...stoppedEvents, ...drained, ...closed]) {
|
|
169
175
|
if (result.status === "rejected" && !failures.includes(result.reason))
|
|
170
176
|
failures.push(result.reason);
|
|
@@ -44,6 +44,21 @@ const inputSchema = z.discriminatedUnion("kind", [
|
|
|
44
44
|
.strict(),
|
|
45
45
|
z.object({ kind: z.literal("manual"), text: z.string().min(1) }).strict(),
|
|
46
46
|
]);
|
|
47
|
+
const agentRuntimeSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
workerPid: z.number().int().positive(),
|
|
50
|
+
lifecycleDirectory: z
|
|
51
|
+
.string()
|
|
52
|
+
.min(1)
|
|
53
|
+
.refine(isAbsolute, "Pi worker lifecycle directory must be absolute"),
|
|
54
|
+
runToken: z.string().min(1),
|
|
55
|
+
supervisorLeasePath: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.refine(isAbsolute, "Supervisor lease path must be absolute"),
|
|
59
|
+
orphanAfterMs: z.number().int().positive(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
47
62
|
const runSchema = z
|
|
48
63
|
.object({
|
|
49
64
|
runId: z.string().min(1),
|
|
@@ -65,6 +80,7 @@ const runSchema = z
|
|
|
65
80
|
playbook: z.string().min(1),
|
|
66
81
|
promptDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
67
82
|
systemPrompt: z.string().min(1).optional(),
|
|
83
|
+
agent: agentRuntimeSchema.optional(),
|
|
68
84
|
failure: failureSchema.optional(),
|
|
69
85
|
})
|
|
70
86
|
.strict();
|