@nettee/beacon 0.5.1 → 0.6.1
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/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 +22 -0
- package/dist/daemon/run/orchestrator.d.ts +21 -0
- package/dist/daemon/run/orchestrator.js +187 -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 +104 -0
- package/dist/daemon/runtime/pi-worker-lifecycle.js +252 -0
- package/dist/daemon/runtime/pi-worker-main.d.ts +1 -0
- package/dist/daemon/runtime/pi-worker-main.js +225 -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/package.json +1 -1
|
@@ -18,6 +18,18 @@
|
|
|
18
18
|
<key>SuccessfulExit</key>
|
|
19
19
|
<false/>
|
|
20
20
|
</dict>
|
|
21
|
+
<!--
|
|
22
|
+
Beacon 0.6+ spawns Pi in a detached worker (new session / PGID), so a
|
|
23
|
+
plain kill -9 of the daemon should not take in-flight Pi with it.
|
|
24
|
+
AbandonProcessGroup is optional belt-and-suspenders on Mini LaunchDaemon:
|
|
25
|
+
if true, launchd will not kill leftover processes that share the job PGID
|
|
26
|
+
when the job exits. Prefer relying on code-side detach; set this only if
|
|
27
|
+
you still see Pi die with the daemon after upgrading to 0.6+.
|
|
28
|
+
-->
|
|
29
|
+
<!--
|
|
30
|
+
<key>AbandonProcessGroup</key>
|
|
31
|
+
<true/>
|
|
32
|
+
-->
|
|
21
33
|
<key>ThrottleInterval</key>
|
|
22
34
|
<integer>10</integer>
|
|
23
35
|
<key>Umask</key>
|
|
@@ -99,6 +99,14 @@ export type ObservabilityFeedbackRecord = {
|
|
|
99
99
|
submittedAt: string;
|
|
100
100
|
};
|
|
101
101
|
export type DeliveryContent = TextReplyContent | CardContent;
|
|
102
|
+
/** Detached Pi worker handle persisted so a new Beacon daemon can adopt the Run. */
|
|
103
|
+
export type AgentRuntimeRecord = {
|
|
104
|
+
workerPid: number;
|
|
105
|
+
lifecycleDirectory: string;
|
|
106
|
+
runToken: string;
|
|
107
|
+
supervisorLeasePath: string;
|
|
108
|
+
orphanAfterMs: number;
|
|
109
|
+
};
|
|
102
110
|
export type RunRecord = {
|
|
103
111
|
runId: string;
|
|
104
112
|
/** Pi session ID. Absent only on records written before session persistence. */
|
|
@@ -115,6 +123,8 @@ export type RunRecord = {
|
|
|
115
123
|
promptDigest: string;
|
|
116
124
|
/** Full `--system-prompt` text sent to Pi. Absent on records written before this field. */
|
|
117
125
|
systemPrompt?: string | undefined;
|
|
126
|
+
/** Present while a detached Pi worker owns the in-flight Run. */
|
|
127
|
+
agent?: AgentRuntimeRecord | undefined;
|
|
118
128
|
failure?: Failure | undefined;
|
|
119
129
|
};
|
|
120
130
|
export type FinalOutcomeRecord = {
|
|
@@ -20,4 +20,6 @@ export type OutcomeServer = OutcomeSink & {
|
|
|
20
20
|
};
|
|
21
21
|
export declare function startOutcomeServer(options?: {
|
|
22
22
|
maxRequestBytes?: number | undefined;
|
|
23
|
+
/** Stable directory (e.g. ~/.beacon/state/outcomes). Ephemeral /tmp when omitted. */
|
|
24
|
+
directory?: string | undefined;
|
|
23
25
|
}): Promise<OutcomeServer>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import { createServer } from "node:net";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -12,8 +12,14 @@ export async function startOutcomeServer(options = {}) {
|
|
|
12
12
|
if (!Number.isSafeInteger(maxRequestBytes) || maxRequestBytes <= 0) {
|
|
13
13
|
throw new Error("Outcome request limit must be a positive integer");
|
|
14
14
|
}
|
|
15
|
-
const
|
|
15
|
+
const ephemeral = options.directory === undefined;
|
|
16
|
+
const directory = options.directory ?? (await mkdtemp(join(tmpdir(), "beacon-outcomes-")));
|
|
17
|
+
if (!ephemeral) {
|
|
18
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
19
|
+
await chmod(directory, 0o700);
|
|
20
|
+
}
|
|
16
21
|
const socketPath = join(directory, "outcomes.sock");
|
|
22
|
+
await rm(socketPath, { force: true });
|
|
17
23
|
const submissions = new Map();
|
|
18
24
|
const server = createServer((socket) => {
|
|
19
25
|
socket.setEncoding("utf8");
|
|
@@ -99,6 +105,7 @@ export async function startOutcomeServer(options = {}) {
|
|
|
99
105
|
resolve();
|
|
100
106
|
});
|
|
101
107
|
});
|
|
108
|
+
await chmod(socketPath, 0o600).catch(() => undefined);
|
|
102
109
|
return {
|
|
103
110
|
openRun() {
|
|
104
111
|
const runToken = randomBytes(32).toString("base64url");
|
|
@@ -139,7 +146,10 @@ export async function startOutcomeServer(options = {}) {
|
|
|
139
146
|
await new Promise((resolve, reject) => {
|
|
140
147
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
141
148
|
});
|
|
142
|
-
await rm(
|
|
149
|
+
await rm(socketPath, { force: true });
|
|
150
|
+
if (ephemeral) {
|
|
151
|
+
await rm(directory, { recursive: true, force: true });
|
|
152
|
+
}
|
|
143
153
|
},
|
|
144
154
|
};
|
|
145
155
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { FinalOutcomeContent } from "../domain/types.js";
|
|
2
2
|
import { completeOutcome, mergeOutcome, type OutcomePatch } from "./content.js";
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Submit a Final Outcome patch. Retries briefly on connection errors so a
|
|
5
|
+
* detached Pi worker can survive a short Beacon restart window.
|
|
6
|
+
*/
|
|
7
|
+
export declare function submitOutcome(socketPath: string, runToken: string, outcome: OutcomePatch | FinalOutcomeContent, options?: {
|
|
8
|
+
retries?: number | undefined;
|
|
9
|
+
retryDelayMs?: number | undefined;
|
|
10
|
+
}): Promise<void>;
|
|
4
11
|
export declare function submitOutcomeFromCli(): Promise<void>;
|
|
5
12
|
export { completeOutcome, mergeOutcome };
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createConnection } from "node:net";
|
|
2
2
|
import { completeOutcome, mergeOutcome, parseOutcomePatch, } from "./content.js";
|
|
3
|
-
|
|
4
|
-
const patch = parseOutcomePatch(outcome);
|
|
3
|
+
async function submitOutcomeOnce(socketPath, runToken, patch) {
|
|
5
4
|
await new Promise((resolve, reject) => {
|
|
6
5
|
const socket = createConnection(socketPath);
|
|
7
6
|
socket.setEncoding("utf8");
|
|
@@ -32,6 +31,38 @@ export async function submitOutcome(socketPath, runToken, outcome) {
|
|
|
32
31
|
});
|
|
33
32
|
});
|
|
34
33
|
}
|
|
34
|
+
function isRetryableSubmitError(error) {
|
|
35
|
+
if (!error || typeof error !== "object")
|
|
36
|
+
return false;
|
|
37
|
+
const code = "code" in error ? String(error.code) : "";
|
|
38
|
+
return (code === "ENOENT" ||
|
|
39
|
+
code === "ECONNREFUSED" ||
|
|
40
|
+
code === "ECONNRESET" ||
|
|
41
|
+
code === "EPIPE");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Submit a Final Outcome patch. Retries briefly on connection errors so a
|
|
45
|
+
* detached Pi worker can survive a short Beacon restart window.
|
|
46
|
+
*/
|
|
47
|
+
export async function submitOutcome(socketPath, runToken, outcome, options = {}) {
|
|
48
|
+
const patch = parseOutcomePatch(outcome);
|
|
49
|
+
const retries = options.retries ?? 40;
|
|
50
|
+
const retryDelayMs = options.retryDelayMs ?? 500;
|
|
51
|
+
let lastError;
|
|
52
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
53
|
+
try {
|
|
54
|
+
await submitOutcomeOnce(socketPath, runToken, patch);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
lastError = error;
|
|
59
|
+
if (attempt === retries || !isRetryableSubmitError(error))
|
|
60
|
+
throw error;
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw lastError;
|
|
65
|
+
}
|
|
35
66
|
export async function submitOutcomeFromCli() {
|
|
36
67
|
const socketPath = process.env.BEACON_OUTCOME_SOCKET;
|
|
37
68
|
const runToken = process.env.BEACON_RUN_TOKEN;
|
|
@@ -4,7 +4,9 @@ import type { OutcomeServer } from "../outcome/server.js";
|
|
|
4
4
|
import type { TriggerStore } from "../state/trigger-store.js";
|
|
5
5
|
import { type DeliveryAdapter, RunOrchestrator } from "./orchestrator.js";
|
|
6
6
|
import type { RunQueue } from "./queue.js";
|
|
7
|
-
export type PiOrchestratorConfig = Pick<GlobalConfig, "pi" | "runs"
|
|
7
|
+
export type PiOrchestratorConfig = Pick<GlobalConfig, "pi" | "runs"> & {
|
|
8
|
+
homeDirectory: string;
|
|
9
|
+
};
|
|
8
10
|
export declare function createPiRunOrchestrator(options: {
|
|
9
11
|
beaconCliPath: string;
|
|
10
12
|
config: PiOrchestratorConfig;
|
|
@@ -14,4 +16,7 @@ export declare function createPiRunOrchestrator(options: {
|
|
|
14
16
|
queue: RunQueue;
|
|
15
17
|
outcomes: OutcomeServer;
|
|
16
18
|
delivery: DeliveryAdapter;
|
|
19
|
+
/** Override for tests; production always detaches. */
|
|
20
|
+
detachRuns?: boolean | undefined;
|
|
21
|
+
orphanAfterMs?: number | undefined;
|
|
17
22
|
}): RunOrchestrator;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { runPiAgent } from "../runtime/pi-rpc.js";
|
|
3
|
+
import { DEFAULT_ORPHAN_AFTER_MS } from "../runtime/supervisor-lease.js";
|
|
2
4
|
import { RunOrchestrator } from "./orchestrator.js";
|
|
3
5
|
export function createPiRunOrchestrator(options) {
|
|
6
|
+
const detachRuns = options.detachRuns ?? true;
|
|
7
|
+
const stateDirectory = join(options.config.homeDirectory, "state");
|
|
8
|
+
const supervisorLeasePath = join(stateDirectory, "supervisor.lease");
|
|
9
|
+
const outcomesDirectory = join(stateDirectory, "outcomes");
|
|
4
10
|
return new RunOrchestrator({
|
|
5
11
|
profile: options.profile,
|
|
6
12
|
store: options.store,
|
|
@@ -18,5 +24,21 @@ export function createPiRunOrchestrator(options) {
|
|
|
18
24
|
runtimeEnvironment: options.runtimeEnvironment,
|
|
19
25
|
}),
|
|
20
26
|
delivery: options.delivery,
|
|
27
|
+
...(detachRuns
|
|
28
|
+
? {
|
|
29
|
+
detached: {
|
|
30
|
+
supervisorLeasePath,
|
|
31
|
+
outcomesDirectory,
|
|
32
|
+
orphanAfterMs: options.orphanAfterMs ?? DEFAULT_ORPHAN_AFTER_MS,
|
|
33
|
+
executable: options.config.pi.executable,
|
|
34
|
+
timeoutMs: options.config.runs.timeoutSeconds * 1_000,
|
|
35
|
+
terminateGraceMs: options.config.runs.terminateGraceSeconds * 1_000,
|
|
36
|
+
environment: {
|
|
37
|
+
PI_CODING_AGENT_DIR: options.config.pi.codingAgentDirectory,
|
|
38
|
+
},
|
|
39
|
+
runtimeEnvironment: options.runtimeEnvironment,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
: {}),
|
|
21
43
|
});
|
|
22
44
|
}
|
|
@@ -9,6 +9,18 @@ export type DeliveryAdapter = {
|
|
|
9
9
|
providerRequestId?: string | undefined;
|
|
10
10
|
}>;
|
|
11
11
|
};
|
|
12
|
+
export type DetachedRunOptions = {
|
|
13
|
+
supervisorLeasePath: string;
|
|
14
|
+
/** Directory for short per-run Outcome sockets (e.g. ~/.beacon/state/outcomes). */
|
|
15
|
+
outcomesDirectory: string;
|
|
16
|
+
orphanAfterMs?: number | undefined;
|
|
17
|
+
executable?: string | undefined;
|
|
18
|
+
timeoutMs?: number | undefined;
|
|
19
|
+
terminateGraceMs?: number | undefined;
|
|
20
|
+
environment?: NodeJS.ProcessEnv | undefined;
|
|
21
|
+
runtimeEnvironment?: NodeJS.ProcessEnv | undefined;
|
|
22
|
+
workerMainPath?: string | undefined;
|
|
23
|
+
};
|
|
12
24
|
export type RunOrchestratorOptions = {
|
|
13
25
|
profile: Profile;
|
|
14
26
|
store: TriggerStore;
|
|
@@ -18,6 +30,11 @@ export type RunOrchestratorOptions = {
|
|
|
18
30
|
runAgent: AgentRuntimeRunner;
|
|
19
31
|
delivery: DeliveryAdapter;
|
|
20
32
|
sessionDirectory: string;
|
|
33
|
+
/**
|
|
34
|
+
* When set, Pi runs in a detached worker process that can outlive Beacon
|
|
35
|
+
* restarts. Tests omit this and keep the in-process `runAgent` path.
|
|
36
|
+
*/
|
|
37
|
+
detached?: DetachedRunOptions | undefined;
|
|
21
38
|
now?: (() => Date) | undefined;
|
|
22
39
|
id?: (() => string) | undefined;
|
|
23
40
|
};
|
|
@@ -55,7 +72,11 @@ export declare class RunOrchestrator {
|
|
|
55
72
|
private deliverField;
|
|
56
73
|
private deliverOutcome;
|
|
57
74
|
private fail;
|
|
75
|
+
private finalizeWorkerCompletion;
|
|
76
|
+
private executeDetached;
|
|
77
|
+
private executeInProcess;
|
|
58
78
|
private execute;
|
|
79
|
+
private adoptDetachedRun;
|
|
59
80
|
process(triggerKey: string, normalize: () => Promise<TriggerInput>): Promise<void>;
|
|
60
81
|
recover(): Promise<void>;
|
|
61
82
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import { isMissingReplyOutcomeError, MISSING_REPLY_OUTCOME_SUMMARY, } from "../outcome/content.js";
|
|
4
4
|
import { inboundOutOfRoleReply } from "../outcome/instructions.js";
|
|
5
5
|
import { PiRuntimeError, RUNTIME_TIMEOUT_OUTCOME_MISSING_NOTE, RUNTIME_TIMEOUT_OUTCOME_SUBMITTED_NOTE, } from "../runtime/pi-rpc.js";
|
|
6
|
+
import { isWorkerAdoptable, resolveWorkerOutcomeSocketPath, signalWorker, spawnDetachedPiWorker, waitForWorkerCompletion, } from "../runtime/pi-worker-lifecycle.js";
|
|
7
|
+
import { DEFAULT_ORPHAN_AFTER_MS } from "../runtime/supervisor-lease.js";
|
|
6
8
|
import { agentSystemPromptTrigger, buildAgentSystemPrompt, profilePromptDigest, } from "../runtime/system-prompt.js";
|
|
7
9
|
function promptFor(input) {
|
|
8
10
|
if (input.kind === "event") {
|
|
@@ -302,6 +304,7 @@ export class RunOrchestrator {
|
|
|
302
304
|
state: "failed",
|
|
303
305
|
finishedAt: this.timestamp(),
|
|
304
306
|
failure,
|
|
307
|
+
agent: undefined,
|
|
305
308
|
},
|
|
306
309
|
finalOutcome: {
|
|
307
310
|
origin: "beacon_failure",
|
|
@@ -315,32 +318,114 @@ export class RunOrchestrator {
|
|
|
315
318
|
}));
|
|
316
319
|
await this.deliverOutcome(triggerKey);
|
|
317
320
|
}
|
|
318
|
-
async
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
321
|
+
async finalizeWorkerCompletion(triggerKey, input, runId, notifyTarget, completion) {
|
|
322
|
+
if (completion.ok) {
|
|
323
|
+
const outcome = normalizeAgentOutcome(input, completion.outcome, notifyTarget);
|
|
324
|
+
const updated = await this.options.store.update(triggerKey, (current) => {
|
|
325
|
+
// Exactly-once terminal write: first waiter wins.
|
|
326
|
+
if (current.finalOutcome ||
|
|
327
|
+
current.run?.state === "succeeded" ||
|
|
328
|
+
current.run?.state === "failed") {
|
|
329
|
+
return current;
|
|
327
330
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
+
return {
|
|
332
|
+
...current,
|
|
333
|
+
run: {
|
|
334
|
+
...current.run,
|
|
335
|
+
state: "succeeded",
|
|
336
|
+
finishedAt: this.timestamp(),
|
|
337
|
+
provider: completion.result.provider,
|
|
338
|
+
model: completion.result.model,
|
|
339
|
+
agent: undefined,
|
|
340
|
+
},
|
|
341
|
+
finalOutcome: {
|
|
342
|
+
origin: "agent",
|
|
343
|
+
content: outcome,
|
|
344
|
+
submittedAt: this.timestamp(),
|
|
345
|
+
},
|
|
346
|
+
...(completion.feedback ? { feedback: completion.feedback } : {}),
|
|
347
|
+
...(completion.observabilityFeedback
|
|
348
|
+
? { observabilityFeedback: completion.observabilityFeedback }
|
|
349
|
+
: {}),
|
|
331
350
|
};
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
351
|
+
});
|
|
352
|
+
if (updated.run?.state === "succeeded" && updated.finalOutcome) {
|
|
353
|
+
await this.deliverOutcome(triggerKey);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
let failError = new Error(completion.error.message);
|
|
358
|
+
let code = completion.error.code === "orphan_abandoned" ||
|
|
359
|
+
completion.error.code === "worker_failed"
|
|
360
|
+
? "runtime_exit_failed"
|
|
361
|
+
: completion.error.code;
|
|
362
|
+
if (completion.error.code === "runtime_timeout") {
|
|
363
|
+
const note = completion.hasReply
|
|
364
|
+
? RUNTIME_TIMEOUT_OUTCOME_SUBMITTED_NOTE
|
|
365
|
+
: RUNTIME_TIMEOUT_OUTCOME_MISSING_NOTE;
|
|
366
|
+
failError = new PiRuntimeError("runtime_timeout", `${completion.error.message}. ${note}`);
|
|
367
|
+
code = "runtime_timeout";
|
|
368
|
+
}
|
|
369
|
+
else if (completion.error.code === "orphan_abandoned") {
|
|
370
|
+
code = "service_interrupted";
|
|
371
|
+
failError = new Error(completion.error.message);
|
|
372
|
+
}
|
|
373
|
+
else if (completion.error.code === "runtime_spawn_failed" ||
|
|
374
|
+
completion.error.code === "runtime_protocol_error" ||
|
|
375
|
+
completion.error.code === "runtime_exit_failed") {
|
|
376
|
+
failError = new PiRuntimeError(completion.error.code, completion.error.message);
|
|
377
|
+
}
|
|
378
|
+
await this.fail(triggerKey, runId, code, failError, completion.feedback, completion.observabilityFeedback);
|
|
379
|
+
}
|
|
380
|
+
async executeDetached(triggerKey, input, runId, record) {
|
|
381
|
+
const detached = this.options.detached;
|
|
382
|
+
const sessionPath = record.run.sessionPath;
|
|
383
|
+
const lifecycleDirectory = join(sessionPath, "beacon-runtime");
|
|
384
|
+
const runToken = randomBytes(32).toString("base64url");
|
|
385
|
+
const orphanAfterMs = detached.orphanAfterMs ?? DEFAULT_ORPHAN_AFTER_MS;
|
|
386
|
+
const outcomeSocketPath = resolveWorkerOutcomeSocketPath(detached.outcomesDirectory, runId);
|
|
387
|
+
const handle = await spawnDetachedPiWorker(lifecycleDirectory, {
|
|
388
|
+
prompt: promptFor(input),
|
|
389
|
+
playbook: this.options.profile.playbook,
|
|
390
|
+
provider: this.options.profile.model.provider,
|
|
391
|
+
model: this.options.profile.model.id,
|
|
392
|
+
systemPrompt: record.run.systemPrompt,
|
|
393
|
+
session: {
|
|
394
|
+
id: record.run.sessionId,
|
|
395
|
+
path: sessionPath,
|
|
396
|
+
name: `Beacon ${this.options.profile.id} ${runId}`,
|
|
397
|
+
},
|
|
398
|
+
outcomeSocketPath,
|
|
399
|
+
...(this.options.profile.skills
|
|
400
|
+
? { skills: this.options.profile.skills }
|
|
401
|
+
: {}),
|
|
402
|
+
}, {
|
|
403
|
+
beaconCliPath: this.options.beaconCliPath,
|
|
404
|
+
runToken,
|
|
405
|
+
supervisorLeasePath: detached.supervisorLeasePath,
|
|
406
|
+
orphanAfterMs,
|
|
407
|
+
executable: detached.executable,
|
|
408
|
+
timeoutMs: detached.timeoutMs,
|
|
409
|
+
terminateGraceMs: detached.terminateGraceMs,
|
|
410
|
+
environment: detached.environment,
|
|
411
|
+
runtimeEnvironment: detached.runtimeEnvironment,
|
|
412
|
+
workerMainPath: detached.workerMainPath,
|
|
343
413
|
});
|
|
414
|
+
const agent = {
|
|
415
|
+
workerPid: handle.workerPid,
|
|
416
|
+
lifecycleDirectory: handle.lifecycleDirectory,
|
|
417
|
+
runToken: handle.runToken,
|
|
418
|
+
supervisorLeasePath: handle.supervisorLeasePath,
|
|
419
|
+
orphanAfterMs: handle.orphanAfterMs,
|
|
420
|
+
};
|
|
421
|
+
await this.options.store.update(triggerKey, (current) => ({
|
|
422
|
+
...current,
|
|
423
|
+
run: { ...current.run, state: "running", agent },
|
|
424
|
+
}));
|
|
425
|
+
const completion = await waitForWorkerCompletion(lifecycleDirectory);
|
|
426
|
+
await this.finalizeWorkerCompletion(triggerKey, input, runId, record.notifyTarget, completion);
|
|
427
|
+
}
|
|
428
|
+
async executeInProcess(triggerKey, input, runId, record) {
|
|
344
429
|
await this.options.store.update(triggerKey, (current) => ({
|
|
345
430
|
...current,
|
|
346
431
|
run: { ...current.run, state: "running" },
|
|
@@ -413,6 +498,65 @@ export class RunOrchestrator {
|
|
|
413
498
|
await this.fail(triggerKey, runId, code, failError, submitted, submittedObservability, settle);
|
|
414
499
|
}
|
|
415
500
|
}
|
|
501
|
+
async execute(triggerKey, input, runId) {
|
|
502
|
+
const record = await this.options.store.update(triggerKey, (current) => {
|
|
503
|
+
if (!current.run) {
|
|
504
|
+
throw new Error(`Cannot execute Trigger ${triggerKey} without a Run`);
|
|
505
|
+
}
|
|
506
|
+
const session = current.run.sessionId && current.run.sessionPath
|
|
507
|
+
? {
|
|
508
|
+
sessionId: current.run.sessionId,
|
|
509
|
+
sessionPath: current.run.sessionPath,
|
|
510
|
+
}
|
|
511
|
+
: {
|
|
512
|
+
sessionId: current.run.runId,
|
|
513
|
+
sessionPath: join(this.options.sessionDirectory, this.options.profile.id, current.run.runId),
|
|
514
|
+
};
|
|
515
|
+
const systemPrompt = this.agentSystemPrompt(this.triggerFor(current, input), current.run.systemPrompt);
|
|
516
|
+
return {
|
|
517
|
+
...current,
|
|
518
|
+
run: {
|
|
519
|
+
...current.run,
|
|
520
|
+
...session,
|
|
521
|
+
state: "starting",
|
|
522
|
+
startedAt: this.timestamp(),
|
|
523
|
+
systemPrompt,
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
if (this.options.detached) {
|
|
528
|
+
await this.executeDetached(triggerKey, input, runId, record);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
await this.executeInProcess(triggerKey, input, runId, record);
|
|
532
|
+
}
|
|
533
|
+
async adoptDetachedRun(triggerKey, agent) {
|
|
534
|
+
const latest = await this.record(triggerKey);
|
|
535
|
+
if (!latest.run || !latest.input) {
|
|
536
|
+
await this.fail(triggerKey, latest.run?.runId ?? `run_${this.id()}`, "service_interrupted", "Service restarted while Run was active");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (!(await isWorkerAdoptable(agent))) {
|
|
540
|
+
signalWorker(agent.workerPid, "SIGTERM");
|
|
541
|
+
await this.fail(triggerKey, latest.run.runId, "service_interrupted", "Service restarted while Run was active");
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
console.log(`[beacon] adopting detached Pi worker pid=${agent.workerPid} run_id=${latest.run.runId}`);
|
|
545
|
+
const completion = await waitForWorkerCompletion(agent.lifecycleDirectory);
|
|
546
|
+
// Exactly-once: skip if another waiter already wrote terminal state / Outcome.
|
|
547
|
+
const after = await this.record(triggerKey);
|
|
548
|
+
if (after.finalOutcome ||
|
|
549
|
+
after.run?.state === "succeeded" ||
|
|
550
|
+
after.run?.state === "failed") {
|
|
551
|
+
if (after.finalOutcome &&
|
|
552
|
+
(after.delivery?.state === "pending" ||
|
|
553
|
+
after.notifyDelivery?.state === "pending")) {
|
|
554
|
+
await this.deliverOutcome(triggerKey, false);
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
await this.finalizeWorkerCompletion(triggerKey, latest.input, latest.run.runId, latest.notifyTarget, completion);
|
|
559
|
+
}
|
|
416
560
|
async process(triggerKey, normalize) {
|
|
417
561
|
const runId = `run_${this.id()}`;
|
|
418
562
|
let input;
|
|
@@ -483,6 +627,25 @@ export class RunOrchestrator {
|
|
|
483
627
|
continue;
|
|
484
628
|
}
|
|
485
629
|
if (latest.run?.state === "starting" || latest.run?.state === "running") {
|
|
630
|
+
const agent = latest.run.agent;
|
|
631
|
+
if (agent && this.options.detached) {
|
|
632
|
+
if (latest.input?.kind === "event") {
|
|
633
|
+
await this.options.queue.enqueueWhenAvailable(this.options.profile.id, () => this.adoptDetachedRun(latest.triggerKey, agent));
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
const queued = this.options.queue.enqueue(this.options.profile.id, () => this.adoptDetachedRun(latest.triggerKey, agent));
|
|
637
|
+
if (!queued.accepted) {
|
|
638
|
+
signalWorker(agent.workerPid, "SIGTERM");
|
|
639
|
+
await this.fail(latest.triggerKey, latest.run.runId, "capacity_exceeded", "Run queue capacity exceeded while adopting detached Pi worker");
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
await queued.completion;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (agent)
|
|
648
|
+
signalWorker(agent.workerPid, "SIGTERM");
|
|
486
649
|
await this.fail(latest.triggerKey, latest.run.runId, "service_interrupted", "Service restarted while Run was active");
|
|
487
650
|
continue;
|
|
488
651
|
}
|
|
@@ -34,6 +34,8 @@ export type PiRuntimeOptions = {
|
|
|
34
34
|
runtimeEnvironment?: NodeJS.ProcessEnv | undefined;
|
|
35
35
|
terminateGraceMs?: number | undefined;
|
|
36
36
|
maxFrameBytes?: number | undefined;
|
|
37
|
+
/** When aborted, Beacon stops Pi (SIGTERM → SIGKILL) like a hard timeout. */
|
|
38
|
+
signal?: AbortSignal | undefined;
|
|
37
39
|
};
|
|
38
40
|
export declare class PiRuntimeError extends Error {
|
|
39
41
|
readonly code: "runtime_spawn_failed" | "runtime_protocol_error" | "runtime_timeout" | "runtime_exit_failed";
|
|
@@ -324,6 +324,9 @@ export async function runPiAgent(request, options = {}) {
|
|
|
324
324
|
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
|
|
325
325
|
throw new Error("Pi RPC frame limit must be a positive integer");
|
|
326
326
|
}
|
|
327
|
+
if (options.signal?.aborted) {
|
|
328
|
+
throw new PiRuntimeError("runtime_exit_failed", "Pi Run aborted before spawn");
|
|
329
|
+
}
|
|
327
330
|
const child = spawn(executable, buildArguments(request), {
|
|
328
331
|
cwd: request.playbook,
|
|
329
332
|
env: buildPiEnvironment(request.outcome, options.environment, options.runtimeEnvironment),
|
|
@@ -345,8 +348,13 @@ export async function runPiAgent(request, options = {}) {
|
|
|
345
348
|
return;
|
|
346
349
|
finished = true;
|
|
347
350
|
clearTimeout(timer);
|
|
351
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
348
352
|
reject(error);
|
|
349
353
|
};
|
|
354
|
+
const onAbort = () => {
|
|
355
|
+
fail(new PiRuntimeError("runtime_exit_failed", "Pi Run aborted by Beacon supervisor (orphan safety or cancel)"));
|
|
356
|
+
};
|
|
357
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
350
358
|
const timer = setTimeout(() => fail(new PiRuntimeError("runtime_timeout", formatRuntimeTimeoutMessage(timeoutMs, stderr))), timeoutMs);
|
|
351
359
|
const handleFrame = (line) => {
|
|
352
360
|
if (finished)
|
|
@@ -400,6 +408,7 @@ export async function runPiAgent(request, options = {}) {
|
|
|
400
408
|
};
|
|
401
409
|
finished = true;
|
|
402
410
|
clearTimeout(timer);
|
|
411
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
403
412
|
resolve(result);
|
|
404
413
|
}
|
|
405
414
|
catch (error) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ProfileSkills } from "../config/profile.js";
|
|
2
|
+
import type { FeedbackRecord, FinalOutcomeContent, ObservabilityFeedbackRecord } from "../domain/types.js";
|
|
3
|
+
import type { PiRunResult, PiRuntimeOptions } from "./pi-rpc.js";
|
|
4
|
+
/**
|
|
5
|
+
* Usable AF_UNIX path length on macOS (`sun_path` is 104 including NUL).
|
|
6
|
+
* Enforced on all platforms so Linux CI catches oversize paths too.
|
|
7
|
+
*/
|
|
8
|
+
export declare const UNIX_SOCKET_PATH_MAX_BYTES = 103;
|
|
9
|
+
export type PiWorkerRequest = {
|
|
10
|
+
prompt: string;
|
|
11
|
+
playbook: string;
|
|
12
|
+
provider?: string | undefined;
|
|
13
|
+
model?: string | undefined;
|
|
14
|
+
systemPrompt?: string | undefined;
|
|
15
|
+
session?: {
|
|
16
|
+
id: string;
|
|
17
|
+
path: string;
|
|
18
|
+
name?: string | undefined;
|
|
19
|
+
} | undefined;
|
|
20
|
+
skills?: ProfileSkills | undefined;
|
|
21
|
+
beaconCliPath: string;
|
|
22
|
+
runToken: string;
|
|
23
|
+
/** Short absolute path under ~/.beacon/state/outcomes/<16hex>.sock */
|
|
24
|
+
outcomeSocketPath: string;
|
|
25
|
+
supervisorLeasePath: string;
|
|
26
|
+
orphanAfterMs?: number | undefined;
|
|
27
|
+
executable?: string | undefined;
|
|
28
|
+
timeoutMs?: number | undefined;
|
|
29
|
+
terminateGraceMs?: number | undefined;
|
|
30
|
+
maxFrameBytes?: number | undefined;
|
|
31
|
+
environment?: NodeJS.ProcessEnv | undefined;
|
|
32
|
+
runtimeEnvironment?: NodeJS.ProcessEnv | undefined;
|
|
33
|
+
};
|
|
34
|
+
/** Stable, short per-run Outcome socket under the daemon outcomes directory. */
|
|
35
|
+
export declare function resolveWorkerOutcomeSocketPath(outcomesDirectory: string, runId: string): string;
|
|
36
|
+
export type PiWorkerCompletion = {
|
|
37
|
+
ok: true;
|
|
38
|
+
result: PiRunResult;
|
|
39
|
+
outcome: FinalOutcomeContent;
|
|
40
|
+
feedback?: FeedbackRecord | undefined;
|
|
41
|
+
observabilityFeedback?: ObservabilityFeedbackRecord | undefined;
|
|
42
|
+
hasReply: boolean;
|
|
43
|
+
finishedAt: string;
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
error: {
|
|
47
|
+
code: "runtime_spawn_failed" | "runtime_protocol_error" | "runtime_timeout" | "runtime_exit_failed" | "orphan_abandoned" | "worker_failed";
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
hasReply: boolean;
|
|
51
|
+
feedback?: FeedbackRecord | undefined;
|
|
52
|
+
observabilityFeedback?: ObservabilityFeedbackRecord | undefined;
|
|
53
|
+
finishedAt: string;
|
|
54
|
+
};
|
|
55
|
+
export type AgentRuntimeHandle = {
|
|
56
|
+
workerPid: number;
|
|
57
|
+
lifecycleDirectory: string;
|
|
58
|
+
runToken: string;
|
|
59
|
+
supervisorLeasePath: string;
|
|
60
|
+
orphanAfterMs: number;
|
|
61
|
+
};
|
|
62
|
+
export declare function lifecyclePaths(lifecycleDirectory: string): {
|
|
63
|
+
request: string;
|
|
64
|
+
completion: string;
|
|
65
|
+
heartbeat: string;
|
|
66
|
+
pid: string;
|
|
67
|
+
/** @deprecated 0.6.0 bound here; 0.6.1+ uses resolveWorkerOutcomeSocketPath. */
|
|
68
|
+
legacyOutcomeSocket: string;
|
|
69
|
+
};
|
|
70
|
+
export declare function prepareLifecycleDirectory(lifecycleDirectory: string, options?: {
|
|
71
|
+
outcomeSocketPath?: string | undefined;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
export declare function writeWorkerRequest(lifecycleDirectory: string, request: PiWorkerRequest): Promise<void>;
|
|
74
|
+
export declare function readWorkerRequest(lifecycleDirectory: string): Promise<PiWorkerRequest>;
|
|
75
|
+
export declare function writeWorkerHeartbeat(lifecycleDirectory: string): Promise<void>;
|
|
76
|
+
export declare function writeWorkerPid(lifecycleDirectory: string, pid: number): Promise<void>;
|
|
77
|
+
export declare function writeWorkerCompletion(lifecycleDirectory: string, completion: PiWorkerCompletion): Promise<void>;
|
|
78
|
+
export declare function readWorkerCompletion(lifecycleDirectory: string): Promise<PiWorkerCompletion | undefined>;
|
|
79
|
+
export declare function readWorkerHeartbeat(lifecycleDirectory: string): Promise<{
|
|
80
|
+
pid: number;
|
|
81
|
+
updatedAt: string;
|
|
82
|
+
} | undefined>;
|
|
83
|
+
export declare function readWorkerPid(lifecycleDirectory: string): Promise<number | undefined>;
|
|
84
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
85
|
+
export declare function isWorkerAdoptable(handle: Pick<AgentRuntimeHandle, "workerPid" | "lifecycleDirectory" | "orphanAfterMs">, nowMs?: number, heartbeatMaxAgeMs?: number): Promise<boolean>;
|
|
86
|
+
export declare function waitForWorkerCompletion(lifecycleDirectory: string, options?: {
|
|
87
|
+
pollIntervalMs?: number | undefined;
|
|
88
|
+
abortSignal?: AbortSignal | undefined;
|
|
89
|
+
}): Promise<PiWorkerCompletion>;
|
|
90
|
+
export declare function resolvePiWorkerMainPath(): string;
|
|
91
|
+
export type SpawnDetachedWorkerOptions = PiRuntimeOptions & {
|
|
92
|
+
beaconCliPath: string;
|
|
93
|
+
runToken: string;
|
|
94
|
+
supervisorLeasePath: string;
|
|
95
|
+
orphanAfterMs?: number | undefined;
|
|
96
|
+
workerMainPath?: string | undefined;
|
|
97
|
+
};
|
|
98
|
+
export declare function spawnDetachedPiWorker(lifecycleDirectory: string, request: Omit<PiWorkerRequest, "beaconCliPath" | "runToken" | "supervisorLeasePath" | "orphanAfterMs" | "executable" | "timeoutMs" | "terminateGraceMs" | "maxFrameBytes" | "environment" | "runtimeEnvironment"> & {
|
|
99
|
+
prompt: string;
|
|
100
|
+
playbook: string;
|
|
101
|
+
}, options: SpawnDetachedWorkerOptions): Promise<AgentRuntimeHandle>;
|
|
102
|
+
/** Stop a still-living worker (orphan / abandon). Best-effort. */
|
|
103
|
+
export declare function signalWorker(pid: number, signal?: NodeJS.Signals): void;
|
|
104
|
+
export declare function assertSupervisorOrThrow(supervisorLeasePath: string, orphanAfterMs: number): Promise<void>;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { access, chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { DEFAULT_ORPHAN_AFTER_MS, isSupervisorLeaseFresh, } from "./supervisor-lease.js";
|
|
7
|
+
/**
|
|
8
|
+
* Usable AF_UNIX path length on macOS (`sun_path` is 104 including NUL).
|
|
9
|
+
* Enforced on all platforms so Linux CI catches oversize paths too.
|
|
10
|
+
*/
|
|
11
|
+
export const UNIX_SOCKET_PATH_MAX_BYTES = 103;
|
|
12
|
+
/** Stable, short per-run Outcome socket under the daemon outcomes directory. */
|
|
13
|
+
export function resolveWorkerOutcomeSocketPath(outcomesDirectory, runId) {
|
|
14
|
+
if (!isAbsolute(outcomesDirectory)) {
|
|
15
|
+
throw new Error("Worker outcome socket directory must be absolute");
|
|
16
|
+
}
|
|
17
|
+
if (!runId) {
|
|
18
|
+
throw new Error("Worker outcome socket requires a run id");
|
|
19
|
+
}
|
|
20
|
+
const shortId = createHash("sha256").update(runId).digest("hex").slice(0, 16);
|
|
21
|
+
const socketPath = join(outcomesDirectory, `${shortId}.sock`);
|
|
22
|
+
const bytes = Buffer.byteLength(socketPath, "utf8");
|
|
23
|
+
if (bytes > UNIX_SOCKET_PATH_MAX_BYTES) {
|
|
24
|
+
throw new Error(`Worker outcome socket path exceeds Unix sun_path limit (${bytes} > ${UNIX_SOCKET_PATH_MAX_BYTES} bytes): ${socketPath}`);
|
|
25
|
+
}
|
|
26
|
+
return socketPath;
|
|
27
|
+
}
|
|
28
|
+
const REQUEST_FILE = "request.json";
|
|
29
|
+
const COMPLETION_FILE = "completion.json";
|
|
30
|
+
const HEARTBEAT_FILE = "worker.heartbeat";
|
|
31
|
+
const PID_FILE = "worker.pid";
|
|
32
|
+
export function lifecyclePaths(lifecycleDirectory) {
|
|
33
|
+
return {
|
|
34
|
+
request: join(lifecycleDirectory, REQUEST_FILE),
|
|
35
|
+
completion: join(lifecycleDirectory, COMPLETION_FILE),
|
|
36
|
+
heartbeat: join(lifecycleDirectory, HEARTBEAT_FILE),
|
|
37
|
+
pid: join(lifecycleDirectory, PID_FILE),
|
|
38
|
+
/** @deprecated 0.6.0 bound here; 0.6.1+ uses resolveWorkerOutcomeSocketPath. */
|
|
39
|
+
legacyOutcomeSocket: join(lifecycleDirectory, "outcomes.sock"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function prepareLifecycleDirectory(lifecycleDirectory, options = {}) {
|
|
43
|
+
if (!isAbsolute(lifecycleDirectory)) {
|
|
44
|
+
throw new Error("Pi worker lifecycle directory must be absolute");
|
|
45
|
+
}
|
|
46
|
+
await mkdir(lifecycleDirectory, { recursive: true, mode: 0o700 });
|
|
47
|
+
await chmod(lifecycleDirectory, 0o700);
|
|
48
|
+
const paths = lifecyclePaths(lifecycleDirectory);
|
|
49
|
+
await rm(paths.completion, { force: true });
|
|
50
|
+
await rm(paths.heartbeat, { force: true });
|
|
51
|
+
await rm(paths.pid, { force: true });
|
|
52
|
+
await rm(paths.legacyOutcomeSocket, { force: true });
|
|
53
|
+
if (options.outcomeSocketPath) {
|
|
54
|
+
if (!isAbsolute(options.outcomeSocketPath)) {
|
|
55
|
+
throw new Error("Worker outcome socket path must be absolute");
|
|
56
|
+
}
|
|
57
|
+
await mkdir(dirname(options.outcomeSocketPath), {
|
|
58
|
+
recursive: true,
|
|
59
|
+
mode: 0o700,
|
|
60
|
+
});
|
|
61
|
+
await rm(options.outcomeSocketPath, { force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function writeWorkerRequest(lifecycleDirectory, request) {
|
|
65
|
+
if (!request.outcomeSocketPath || !isAbsolute(request.outcomeSocketPath)) {
|
|
66
|
+
throw new Error("Pi worker request requires an absolute outcomeSocketPath");
|
|
67
|
+
}
|
|
68
|
+
await prepareLifecycleDirectory(lifecycleDirectory, {
|
|
69
|
+
outcomeSocketPath: request.outcomeSocketPath,
|
|
70
|
+
});
|
|
71
|
+
const temporary = `${lifecyclePaths(lifecycleDirectory).request}.tmp`;
|
|
72
|
+
await writeFile(temporary, `${JSON.stringify(request, null, 2)}\n`, {
|
|
73
|
+
mode: 0o600,
|
|
74
|
+
});
|
|
75
|
+
await rename(temporary, lifecyclePaths(lifecycleDirectory).request);
|
|
76
|
+
}
|
|
77
|
+
export async function readWorkerRequest(lifecycleDirectory) {
|
|
78
|
+
const raw = JSON.parse(await readFile(lifecyclePaths(lifecycleDirectory).request, "utf8"));
|
|
79
|
+
if (!raw.beaconCliPath ||
|
|
80
|
+
!raw.runToken ||
|
|
81
|
+
!raw.supervisorLeasePath ||
|
|
82
|
+
!raw.outcomeSocketPath) {
|
|
83
|
+
throw new Error("Pi worker request is missing required fields");
|
|
84
|
+
}
|
|
85
|
+
if (!isAbsolute(raw.outcomeSocketPath)) {
|
|
86
|
+
throw new Error("Pi worker outcomeSocketPath must be absolute");
|
|
87
|
+
}
|
|
88
|
+
return raw;
|
|
89
|
+
}
|
|
90
|
+
export async function writeWorkerHeartbeat(lifecycleDirectory) {
|
|
91
|
+
const temporary = `${lifecyclePaths(lifecycleDirectory).heartbeat}.tmp`;
|
|
92
|
+
await writeFile(temporary, `${JSON.stringify({ pid: process.pid, updatedAt: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
93
|
+
await rename(temporary, lifecyclePaths(lifecycleDirectory).heartbeat);
|
|
94
|
+
}
|
|
95
|
+
export async function writeWorkerPid(lifecycleDirectory, pid) {
|
|
96
|
+
await writeFile(lifecyclePaths(lifecycleDirectory).pid, `${pid}\n`, {
|
|
97
|
+
mode: 0o600,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export async function writeWorkerCompletion(lifecycleDirectory, completion) {
|
|
101
|
+
const temporary = `${lifecyclePaths(lifecycleDirectory).completion}.tmp`;
|
|
102
|
+
await writeFile(temporary, `${JSON.stringify(completion, null, 2)}\n`, {
|
|
103
|
+
mode: 0o600,
|
|
104
|
+
});
|
|
105
|
+
await rename(temporary, lifecyclePaths(lifecycleDirectory).completion);
|
|
106
|
+
}
|
|
107
|
+
export async function readWorkerCompletion(lifecycleDirectory) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(await readFile(lifecyclePaths(lifecycleDirectory).completion, "utf8"));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export async function readWorkerHeartbeat(lifecycleDirectory) {
|
|
116
|
+
try {
|
|
117
|
+
const raw = JSON.parse(await readFile(lifecyclePaths(lifecycleDirectory).heartbeat, "utf8"));
|
|
118
|
+
if (typeof raw !== "object" ||
|
|
119
|
+
raw === null ||
|
|
120
|
+
!("pid" in raw) ||
|
|
121
|
+
!("updatedAt" in raw) ||
|
|
122
|
+
typeof raw.pid !== "number" ||
|
|
123
|
+
typeof raw.updatedAt !== "string") {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return { pid: raw.pid, updatedAt: raw.updatedAt };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function readWorkerPid(lifecycleDirectory) {
|
|
133
|
+
try {
|
|
134
|
+
const raw = (await readFile(lifecyclePaths(lifecycleDirectory).pid, "utf8")).trim();
|
|
135
|
+
const pid = Number(raw);
|
|
136
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function isProcessAlive(pid) {
|
|
143
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
144
|
+
return false;
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, 0);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function isWorkerAdoptable(handle, nowMs = Date.now(), heartbeatMaxAgeMs = 60_000) {
|
|
154
|
+
if (!isProcessAlive(handle.workerPid))
|
|
155
|
+
return false;
|
|
156
|
+
const completion = await readWorkerCompletion(handle.lifecycleDirectory);
|
|
157
|
+
if (completion)
|
|
158
|
+
return true;
|
|
159
|
+
const heartbeat = await readWorkerHeartbeat(handle.lifecycleDirectory);
|
|
160
|
+
if (!heartbeat)
|
|
161
|
+
return false;
|
|
162
|
+
const updatedAt = Date.parse(heartbeat.updatedAt);
|
|
163
|
+
if (!Number.isFinite(updatedAt))
|
|
164
|
+
return false;
|
|
165
|
+
return nowMs - updatedAt <= heartbeatMaxAgeMs;
|
|
166
|
+
}
|
|
167
|
+
export async function waitForWorkerCompletion(lifecycleDirectory, options = {}) {
|
|
168
|
+
const pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
169
|
+
for (;;) {
|
|
170
|
+
if (options.abortSignal?.aborted) {
|
|
171
|
+
throw new Error("Waiting for Pi worker completion was aborted");
|
|
172
|
+
}
|
|
173
|
+
const completion = await readWorkerCompletion(lifecycleDirectory);
|
|
174
|
+
if (completion)
|
|
175
|
+
return completion;
|
|
176
|
+
await new Promise((resolve) => {
|
|
177
|
+
const timer = setTimeout(resolve, pollIntervalMs);
|
|
178
|
+
options.abortSignal?.addEventListener("abort", () => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
resolve();
|
|
181
|
+
}, { once: true });
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function resolvePiWorkerMainPath() {
|
|
186
|
+
return fileURLToPath(new URL(import.meta.url.endsWith(".ts")
|
|
187
|
+
? "../../dist/runtime/pi-worker-main.js"
|
|
188
|
+
: "./pi-worker-main.js", import.meta.url));
|
|
189
|
+
}
|
|
190
|
+
export async function spawnDetachedPiWorker(lifecycleDirectory, request, options) {
|
|
191
|
+
const orphanAfterMs = options.orphanAfterMs ?? DEFAULT_ORPHAN_AFTER_MS;
|
|
192
|
+
const workerRequest = {
|
|
193
|
+
...request,
|
|
194
|
+
beaconCliPath: options.beaconCliPath,
|
|
195
|
+
runToken: options.runToken,
|
|
196
|
+
supervisorLeasePath: options.supervisorLeasePath,
|
|
197
|
+
orphanAfterMs,
|
|
198
|
+
executable: options.executable,
|
|
199
|
+
timeoutMs: options.timeoutMs,
|
|
200
|
+
terminateGraceMs: options.terminateGraceMs,
|
|
201
|
+
maxFrameBytes: options.maxFrameBytes,
|
|
202
|
+
environment: options.environment,
|
|
203
|
+
runtimeEnvironment: options.runtimeEnvironment,
|
|
204
|
+
};
|
|
205
|
+
await writeWorkerRequest(lifecycleDirectory, workerRequest);
|
|
206
|
+
const workerMainPath = options.workerMainPath ?? resolvePiWorkerMainPath();
|
|
207
|
+
try {
|
|
208
|
+
await access(workerMainPath);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
throw new Error(`Pi worker entry is missing (build the daemon first): ${workerMainPath}`, { cause: error });
|
|
212
|
+
}
|
|
213
|
+
const child = spawn(process.execPath, [workerMainPath, lifecycleDirectory], {
|
|
214
|
+
detached: true,
|
|
215
|
+
stdio: "ignore",
|
|
216
|
+
env: {
|
|
217
|
+
PATH: process.env.PATH,
|
|
218
|
+
HOME: process.env.HOME,
|
|
219
|
+
USER: process.env.USER,
|
|
220
|
+
TMPDIR: process.env.TMPDIR,
|
|
221
|
+
LANG: process.env.LANG,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
if (child.pid === undefined) {
|
|
225
|
+
throw new Error("Failed to spawn detached Pi worker (no pid)");
|
|
226
|
+
}
|
|
227
|
+
child.unref();
|
|
228
|
+
await writeWorkerPid(lifecycleDirectory, child.pid);
|
|
229
|
+
return {
|
|
230
|
+
workerPid: child.pid,
|
|
231
|
+
lifecycleDirectory,
|
|
232
|
+
runToken: options.runToken,
|
|
233
|
+
supervisorLeasePath: options.supervisorLeasePath,
|
|
234
|
+
orphanAfterMs,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** Stop a still-living worker (orphan / abandon). Best-effort. */
|
|
238
|
+
export function signalWorker(pid, signal = "SIGTERM") {
|
|
239
|
+
if (!isProcessAlive(pid))
|
|
240
|
+
return;
|
|
241
|
+
try {
|
|
242
|
+
process.kill(pid, signal);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// already gone
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export async function assertSupervisorOrThrow(supervisorLeasePath, orphanAfterMs) {
|
|
249
|
+
if (!(await isSupervisorLeaseFresh(supervisorLeasePath, orphanAfterMs))) {
|
|
250
|
+
throw new Error(`No Beacon supervisor lease for ${orphanAfterMs}ms; abandoning orphan Pi worker`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { chmod, mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { completeOutcome, mergeOutcome, parseOutcomePatch, } from "../outcome/content.js";
|
|
5
|
+
import { PiRuntimeError, runPiAgent } from "./pi-rpc.js";
|
|
6
|
+
import { assertSupervisorOrThrow, readWorkerRequest, writeWorkerCompletion, writeWorkerHeartbeat, } from "./pi-worker-lifecycle.js";
|
|
7
|
+
import { DEFAULT_ORPHAN_AFTER_MS } from "./supervisor-lease.js";
|
|
8
|
+
function respond(socket, response) {
|
|
9
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
10
|
+
}
|
|
11
|
+
async function startWorkerOutcomeServer(socketPath, runToken, maxRequestBytes = 1024 * 1024) {
|
|
12
|
+
await rm(socketPath, { force: true });
|
|
13
|
+
const record = {};
|
|
14
|
+
const server = createServer((socket) => {
|
|
15
|
+
socket.setEncoding("utf8");
|
|
16
|
+
let buffer = "";
|
|
17
|
+
let finished = false;
|
|
18
|
+
socket.on("data", (chunk) => {
|
|
19
|
+
if (finished)
|
|
20
|
+
return;
|
|
21
|
+
buffer += chunk;
|
|
22
|
+
if (Buffer.byteLength(buffer, "utf8") > maxRequestBytes) {
|
|
23
|
+
finished = true;
|
|
24
|
+
respond(socket, {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: "Outcome submission request is too large",
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const newline = buffer.indexOf("\n");
|
|
31
|
+
if (newline < 0)
|
|
32
|
+
return;
|
|
33
|
+
finished = true;
|
|
34
|
+
let request;
|
|
35
|
+
try {
|
|
36
|
+
request = JSON.parse(buffer.slice(0, newline));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
respond(socket, { ok: false, error: "invalid JSON request" });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (typeof request !== "object" ||
|
|
43
|
+
request === null ||
|
|
44
|
+
!("runToken" in request) ||
|
|
45
|
+
!("outcome" in request) ||
|
|
46
|
+
typeof request.runToken !== "string") {
|
|
47
|
+
respond(socket, {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: "runToken and outcome are required",
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (request.runToken !== runToken) {
|
|
54
|
+
respond(socket, { ok: false, error: "unknown Run Capability" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let patch;
|
|
58
|
+
try {
|
|
59
|
+
patch = parseOutcomePatch(request.outcome);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
respond(socket, {
|
|
63
|
+
ok: false,
|
|
64
|
+
error: `invalid Final Outcome: ${error instanceof Error ? error.message : String(error)}`,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const previous = record.outcome;
|
|
70
|
+
record.outcome = mergeOutcome(record.outcome, patch);
|
|
71
|
+
if (patch.feedback && !previous?.feedback) {
|
|
72
|
+
record.feedbackSubmittedAt = new Date().toISOString();
|
|
73
|
+
}
|
|
74
|
+
if (patch.observabilityFeedback && !previous?.observabilityFeedback) {
|
|
75
|
+
record.observabilityFeedbackSubmittedAt = new Date().toISOString();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
respond(socket, {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: error instanceof Error ? error.message : String(error),
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
respond(socket, { ok: true });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
server.once("error", reject);
|
|
90
|
+
server.listen(socketPath, () => {
|
|
91
|
+
server.off("error", reject);
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
await chmod(socketPath, 0o600);
|
|
96
|
+
return {
|
|
97
|
+
take() {
|
|
98
|
+
return completeOutcome(record.outcome ?? {});
|
|
99
|
+
},
|
|
100
|
+
takeFeedback() {
|
|
101
|
+
const items = record.outcome?.feedback?.items;
|
|
102
|
+
if (!items || record.feedbackSubmittedAt === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
return { items, submittedAt: record.feedbackSubmittedAt };
|
|
105
|
+
},
|
|
106
|
+
takeObservabilityFeedback() {
|
|
107
|
+
const items = record.outcome?.observabilityFeedback?.items;
|
|
108
|
+
if (!items || record.observabilityFeedbackSubmittedAt === undefined) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return { items, submittedAt: record.observabilityFeedbackSubmittedAt };
|
|
112
|
+
},
|
|
113
|
+
hasReply() {
|
|
114
|
+
return record.outcome?.reply !== undefined;
|
|
115
|
+
},
|
|
116
|
+
async close() {
|
|
117
|
+
await new Promise((resolve, reject) => {
|
|
118
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
119
|
+
});
|
|
120
|
+
await rm(socketPath, { force: true });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function runWorker(lifecycleDirectory) {
|
|
125
|
+
const request = await readWorkerRequest(lifecycleDirectory);
|
|
126
|
+
const orphanAfterMs = request.orphanAfterMs ?? DEFAULT_ORPHAN_AFTER_MS;
|
|
127
|
+
const outcomeSocketPath = request.outcomeSocketPath;
|
|
128
|
+
await mkdir(lifecycleDirectory, { recursive: true, mode: 0o700 });
|
|
129
|
+
await mkdir(dirname(outcomeSocketPath), { recursive: true, mode: 0o700 });
|
|
130
|
+
const abort = new AbortController();
|
|
131
|
+
const heartbeat = setInterval(() => {
|
|
132
|
+
void writeWorkerHeartbeat(lifecycleDirectory).catch(() => undefined);
|
|
133
|
+
void assertSupervisorOrThrow(request.supervisorLeasePath, orphanAfterMs)
|
|
134
|
+
.then(() => undefined)
|
|
135
|
+
.catch(() => {
|
|
136
|
+
if (!abort.signal.aborted)
|
|
137
|
+
abort.abort();
|
|
138
|
+
});
|
|
139
|
+
}, 5_000);
|
|
140
|
+
heartbeat.unref?.();
|
|
141
|
+
await writeWorkerHeartbeat(lifecycleDirectory);
|
|
142
|
+
const outcomes = await startWorkerOutcomeServer(outcomeSocketPath, request.runToken);
|
|
143
|
+
const finish = async (completion) => {
|
|
144
|
+
clearInterval(heartbeat);
|
|
145
|
+
await writeWorkerCompletion(lifecycleDirectory, completion);
|
|
146
|
+
await outcomes.close().catch(() => undefined);
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
await assertSupervisorOrThrow(request.supervisorLeasePath, orphanAfterMs);
|
|
150
|
+
const result = await runPiAgent({
|
|
151
|
+
prompt: request.prompt,
|
|
152
|
+
playbook: request.playbook,
|
|
153
|
+
provider: request.provider,
|
|
154
|
+
model: request.model,
|
|
155
|
+
systemPrompt: request.systemPrompt,
|
|
156
|
+
session: request.session,
|
|
157
|
+
skills: request.skills,
|
|
158
|
+
outcome: {
|
|
159
|
+
socketPath: outcomeSocketPath,
|
|
160
|
+
runToken: request.runToken,
|
|
161
|
+
cliPath: request.beaconCliPath,
|
|
162
|
+
},
|
|
163
|
+
}, {
|
|
164
|
+
executable: request.executable,
|
|
165
|
+
timeoutMs: request.timeoutMs,
|
|
166
|
+
terminateGraceMs: request.terminateGraceMs,
|
|
167
|
+
maxFrameBytes: request.maxFrameBytes,
|
|
168
|
+
environment: request.environment,
|
|
169
|
+
runtimeEnvironment: request.runtimeEnvironment,
|
|
170
|
+
signal: abort.signal,
|
|
171
|
+
});
|
|
172
|
+
await finish({
|
|
173
|
+
ok: true,
|
|
174
|
+
result,
|
|
175
|
+
outcome: outcomes.take(),
|
|
176
|
+
feedback: outcomes.takeFeedback(),
|
|
177
|
+
observabilityFeedback: outcomes.takeObservabilityFeedback(),
|
|
178
|
+
hasReply: outcomes.hasReply(),
|
|
179
|
+
finishedAt: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const orphaned = abort.signal.aborted;
|
|
184
|
+
const code = orphaned
|
|
185
|
+
? "orphan_abandoned"
|
|
186
|
+
: error instanceof PiRuntimeError
|
|
187
|
+
? error.code
|
|
188
|
+
: "worker_failed";
|
|
189
|
+
const message = orphaned
|
|
190
|
+
? "Pi worker stopped after Beacon supervisor lease expired (orphan safety)"
|
|
191
|
+
: error instanceof Error
|
|
192
|
+
? error.message
|
|
193
|
+
: String(error);
|
|
194
|
+
await finish({
|
|
195
|
+
ok: false,
|
|
196
|
+
error: { code, message },
|
|
197
|
+
hasReply: outcomes.hasReply(),
|
|
198
|
+
feedback: outcomes.takeFeedback(),
|
|
199
|
+
observabilityFeedback: outcomes.takeObservabilityFeedback(),
|
|
200
|
+
finishedAt: new Date().toISOString(),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const lifecycleDirectory = process.argv[2];
|
|
205
|
+
if (!lifecycleDirectory) {
|
|
206
|
+
console.error("Usage: node pi-worker-main.js <lifecycle-directory>");
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
runWorker(lifecycleDirectory).catch(async (error) => {
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
try {
|
|
213
|
+
await writeWorkerCompletion(lifecycleDirectory, {
|
|
214
|
+
ok: false,
|
|
215
|
+
error: { code: "worker_failed", message },
|
|
216
|
+
hasReply: false,
|
|
217
|
+
finishedAt: new Date().toISOString(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
console.error(`[beacon-pi-worker] fatal: ${message}`);
|
|
222
|
+
}
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -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();
|