@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
|
@@ -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>
|
|
@@ -37,6 +37,8 @@ const skillsSchema = z
|
|
|
37
37
|
paths: z.array(z.string().trim().min(1)).default([]),
|
|
38
38
|
})
|
|
39
39
|
.strict();
|
|
40
|
+
const positiveInteger = z.number().int().positive().safe();
|
|
41
|
+
const nonNegativeInteger = z.number().int().nonnegative().safe();
|
|
40
42
|
const profileDocumentSchema = z
|
|
41
43
|
.object({
|
|
42
44
|
playbook: z.string().min(1),
|
|
@@ -47,6 +49,10 @@ const profileDocumentSchema = z
|
|
|
47
49
|
id: z.string().min(1),
|
|
48
50
|
})
|
|
49
51
|
.strict(),
|
|
52
|
+
/** Optional per-Profile active Run limit; omit → host default. */
|
|
53
|
+
max_concurrent: positiveInteger.optional(),
|
|
54
|
+
/** Optional per-Profile queue depth; omit → host `runs.max_queued`. */
|
|
55
|
+
max_queued: nonNegativeInteger.optional(),
|
|
50
56
|
admin: adminDestinationSchema.optional(),
|
|
51
57
|
schedules: z.array(scheduleSchema).default([]),
|
|
52
58
|
listener: z
|
|
@@ -78,6 +84,8 @@ function toDetailView(profileId, document) {
|
|
|
78
84
|
playbook: document.playbook,
|
|
79
85
|
runtime: document.runtime,
|
|
80
86
|
model: document.model,
|
|
87
|
+
maxConcurrent: document.max_concurrent ?? null,
|
|
88
|
+
maxQueued: document.max_queued ?? null,
|
|
81
89
|
admin: document.admin ? { chatId: document.admin.chat_id } : null,
|
|
82
90
|
schedules: document.schedules.map((schedule) => ({
|
|
83
91
|
id: schedule.id,
|
|
@@ -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,10 @@
|
|
|
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 supervisorLeasePath = join(options.config.homeDirectory, "state", "supervisor.lease");
|
|
4
8
|
return new RunOrchestrator({
|
|
5
9
|
profile: options.profile,
|
|
6
10
|
store: options.store,
|
|
@@ -18,5 +22,20 @@ export function createPiRunOrchestrator(options) {
|
|
|
18
22
|
runtimeEnvironment: options.runtimeEnvironment,
|
|
19
23
|
}),
|
|
20
24
|
delivery: options.delivery,
|
|
25
|
+
...(detachRuns
|
|
26
|
+
? {
|
|
27
|
+
detached: {
|
|
28
|
+
supervisorLeasePath,
|
|
29
|
+
orphanAfterMs: options.orphanAfterMs ?? DEFAULT_ORPHAN_AFTER_MS,
|
|
30
|
+
executable: options.config.pi.executable,
|
|
31
|
+
timeoutMs: options.config.runs.timeoutSeconds * 1_000,
|
|
32
|
+
terminateGraceMs: options.config.runs.terminateGraceSeconds * 1_000,
|
|
33
|
+
environment: {
|
|
34
|
+
PI_CODING_AGENT_DIR: options.config.pi.codingAgentDirectory,
|
|
35
|
+
},
|
|
36
|
+
runtimeEnvironment: options.runtimeEnvironment,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
: {}),
|
|
21
40
|
});
|
|
22
41
|
}
|
|
@@ -9,6 +9,16 @@ export type DeliveryAdapter = {
|
|
|
9
9
|
providerRequestId?: string | undefined;
|
|
10
10
|
}>;
|
|
11
11
|
};
|
|
12
|
+
export type DetachedRunOptions = {
|
|
13
|
+
supervisorLeasePath: string;
|
|
14
|
+
orphanAfterMs?: number | undefined;
|
|
15
|
+
executable?: string | undefined;
|
|
16
|
+
timeoutMs?: number | undefined;
|
|
17
|
+
terminateGraceMs?: number | undefined;
|
|
18
|
+
environment?: NodeJS.ProcessEnv | undefined;
|
|
19
|
+
runtimeEnvironment?: NodeJS.ProcessEnv | undefined;
|
|
20
|
+
workerMainPath?: string | undefined;
|
|
21
|
+
};
|
|
12
22
|
export type RunOrchestratorOptions = {
|
|
13
23
|
profile: Profile;
|
|
14
24
|
store: TriggerStore;
|
|
@@ -18,6 +28,11 @@ export type RunOrchestratorOptions = {
|
|
|
18
28
|
runAgent: AgentRuntimeRunner;
|
|
19
29
|
delivery: DeliveryAdapter;
|
|
20
30
|
sessionDirectory: string;
|
|
31
|
+
/**
|
|
32
|
+
* When set, Pi runs in a detached worker process that can outlive Beacon
|
|
33
|
+
* restarts. Tests omit this and keep the in-process `runAgent` path.
|
|
34
|
+
*/
|
|
35
|
+
detached?: DetachedRunOptions | undefined;
|
|
21
36
|
now?: (() => Date) | undefined;
|
|
22
37
|
id?: (() => string) | undefined;
|
|
23
38
|
};
|
|
@@ -55,7 +70,11 @@ export declare class RunOrchestrator {
|
|
|
55
70
|
private deliverField;
|
|
56
71
|
private deliverOutcome;
|
|
57
72
|
private fail;
|
|
73
|
+
private finalizeWorkerCompletion;
|
|
74
|
+
private executeDetached;
|
|
75
|
+
private executeInProcess;
|
|
58
76
|
private execute;
|
|
77
|
+
private adoptDetachedRun;
|
|
59
78
|
process(triggerKey: string, normalize: () => Promise<TriggerInput>): Promise<void>;
|
|
60
79
|
recover(): Promise<void>;
|
|
61
80
|
}
|
|
@@ -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, 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,112 @@ 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 handle = await spawnDetachedPiWorker(lifecycleDirectory, {
|
|
387
|
+
prompt: promptFor(input),
|
|
388
|
+
playbook: this.options.profile.playbook,
|
|
389
|
+
provider: this.options.profile.model.provider,
|
|
390
|
+
model: this.options.profile.model.id,
|
|
391
|
+
systemPrompt: record.run.systemPrompt,
|
|
392
|
+
session: {
|
|
393
|
+
id: record.run.sessionId,
|
|
394
|
+
path: sessionPath,
|
|
395
|
+
name: `Beacon ${this.options.profile.id} ${runId}`,
|
|
396
|
+
},
|
|
397
|
+
...(this.options.profile.skills
|
|
398
|
+
? { skills: this.options.profile.skills }
|
|
399
|
+
: {}),
|
|
400
|
+
}, {
|
|
401
|
+
beaconCliPath: this.options.beaconCliPath,
|
|
402
|
+
runToken,
|
|
403
|
+
supervisorLeasePath: detached.supervisorLeasePath,
|
|
404
|
+
orphanAfterMs,
|
|
405
|
+
executable: detached.executable,
|
|
406
|
+
timeoutMs: detached.timeoutMs,
|
|
407
|
+
terminateGraceMs: detached.terminateGraceMs,
|
|
408
|
+
environment: detached.environment,
|
|
409
|
+
runtimeEnvironment: detached.runtimeEnvironment,
|
|
410
|
+
workerMainPath: detached.workerMainPath,
|
|
343
411
|
});
|
|
412
|
+
const agent = {
|
|
413
|
+
workerPid: handle.workerPid,
|
|
414
|
+
lifecycleDirectory: handle.lifecycleDirectory,
|
|
415
|
+
runToken: handle.runToken,
|
|
416
|
+
supervisorLeasePath: handle.supervisorLeasePath,
|
|
417
|
+
orphanAfterMs: handle.orphanAfterMs,
|
|
418
|
+
};
|
|
419
|
+
await this.options.store.update(triggerKey, (current) => ({
|
|
420
|
+
...current,
|
|
421
|
+
run: { ...current.run, state: "running", agent },
|
|
422
|
+
}));
|
|
423
|
+
const completion = await waitForWorkerCompletion(lifecycleDirectory);
|
|
424
|
+
await this.finalizeWorkerCompletion(triggerKey, input, runId, record.notifyTarget, completion);
|
|
425
|
+
}
|
|
426
|
+
async executeInProcess(triggerKey, input, runId, record) {
|
|
344
427
|
await this.options.store.update(triggerKey, (current) => ({
|
|
345
428
|
...current,
|
|
346
429
|
run: { ...current.run, state: "running" },
|
|
@@ -413,6 +496,65 @@ export class RunOrchestrator {
|
|
|
413
496
|
await this.fail(triggerKey, runId, code, failError, submitted, submittedObservability, settle);
|
|
414
497
|
}
|
|
415
498
|
}
|
|
499
|
+
async execute(triggerKey, input, runId) {
|
|
500
|
+
const record = await this.options.store.update(triggerKey, (current) => {
|
|
501
|
+
if (!current.run) {
|
|
502
|
+
throw new Error(`Cannot execute Trigger ${triggerKey} without a Run`);
|
|
503
|
+
}
|
|
504
|
+
const session = current.run.sessionId && current.run.sessionPath
|
|
505
|
+
? {
|
|
506
|
+
sessionId: current.run.sessionId,
|
|
507
|
+
sessionPath: current.run.sessionPath,
|
|
508
|
+
}
|
|
509
|
+
: {
|
|
510
|
+
sessionId: current.run.runId,
|
|
511
|
+
sessionPath: join(this.options.sessionDirectory, this.options.profile.id, current.run.runId),
|
|
512
|
+
};
|
|
513
|
+
const systemPrompt = this.agentSystemPrompt(this.triggerFor(current, input), current.run.systemPrompt);
|
|
514
|
+
return {
|
|
515
|
+
...current,
|
|
516
|
+
run: {
|
|
517
|
+
...current.run,
|
|
518
|
+
...session,
|
|
519
|
+
state: "starting",
|
|
520
|
+
startedAt: this.timestamp(),
|
|
521
|
+
systemPrompt,
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
if (this.options.detached) {
|
|
526
|
+
await this.executeDetached(triggerKey, input, runId, record);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
await this.executeInProcess(triggerKey, input, runId, record);
|
|
530
|
+
}
|
|
531
|
+
async adoptDetachedRun(triggerKey, agent) {
|
|
532
|
+
const latest = await this.record(triggerKey);
|
|
533
|
+
if (!latest.run || !latest.input) {
|
|
534
|
+
await this.fail(triggerKey, latest.run?.runId ?? `run_${this.id()}`, "service_interrupted", "Service restarted while Run was active");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!(await isWorkerAdoptable(agent))) {
|
|
538
|
+
signalWorker(agent.workerPid, "SIGTERM");
|
|
539
|
+
await this.fail(triggerKey, latest.run.runId, "service_interrupted", "Service restarted while Run was active");
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
console.log(`[beacon] adopting detached Pi worker pid=${agent.workerPid} run_id=${latest.run.runId}`);
|
|
543
|
+
const completion = await waitForWorkerCompletion(agent.lifecycleDirectory);
|
|
544
|
+
// Exactly-once: skip if another waiter already wrote terminal state / Outcome.
|
|
545
|
+
const after = await this.record(triggerKey);
|
|
546
|
+
if (after.finalOutcome ||
|
|
547
|
+
after.run?.state === "succeeded" ||
|
|
548
|
+
after.run?.state === "failed") {
|
|
549
|
+
if (after.finalOutcome &&
|
|
550
|
+
(after.delivery?.state === "pending" ||
|
|
551
|
+
after.notifyDelivery?.state === "pending")) {
|
|
552
|
+
await this.deliverOutcome(triggerKey, false);
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await this.finalizeWorkerCompletion(triggerKey, latest.input, latest.run.runId, latest.notifyTarget, completion);
|
|
557
|
+
}
|
|
416
558
|
async process(triggerKey, normalize) {
|
|
417
559
|
const runId = `run_${this.id()}`;
|
|
418
560
|
let input;
|
|
@@ -483,6 +625,25 @@ export class RunOrchestrator {
|
|
|
483
625
|
continue;
|
|
484
626
|
}
|
|
485
627
|
if (latest.run?.state === "starting" || latest.run?.state === "running") {
|
|
628
|
+
const agent = latest.run.agent;
|
|
629
|
+
if (agent && this.options.detached) {
|
|
630
|
+
if (latest.input?.kind === "event") {
|
|
631
|
+
await this.options.queue.enqueueWhenAvailable(this.options.profile.id, () => this.adoptDetachedRun(latest.triggerKey, agent));
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
const queued = this.options.queue.enqueue(this.options.profile.id, () => this.adoptDetachedRun(latest.triggerKey, agent));
|
|
635
|
+
if (!queued.accepted) {
|
|
636
|
+
signalWorker(agent.workerPid, "SIGTERM");
|
|
637
|
+
await this.fail(latest.triggerKey, latest.run.runId, "capacity_exceeded", "Run queue capacity exceeded while adopting detached Pi worker");
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
await queued.completion;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (agent)
|
|
646
|
+
signalWorker(agent.workerPid, "SIGTERM");
|
|
486
647
|
await this.fail(latest.triggerKey, latest.run.runId, "service_interrupted", "Service restarted while Run was active");
|
|
487
648
|
continue;
|
|
488
649
|
}
|
|
@@ -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,92 @@
|
|
|
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
|
+
export type PiWorkerRequest = {
|
|
5
|
+
prompt: string;
|
|
6
|
+
playbook: string;
|
|
7
|
+
provider?: string | undefined;
|
|
8
|
+
model?: string | undefined;
|
|
9
|
+
systemPrompt?: string | undefined;
|
|
10
|
+
session?: {
|
|
11
|
+
id: string;
|
|
12
|
+
path: string;
|
|
13
|
+
name?: string | undefined;
|
|
14
|
+
} | undefined;
|
|
15
|
+
skills?: ProfileSkills | undefined;
|
|
16
|
+
beaconCliPath: string;
|
|
17
|
+
runToken: string;
|
|
18
|
+
supervisorLeasePath: string;
|
|
19
|
+
orphanAfterMs?: number | undefined;
|
|
20
|
+
executable?: string | undefined;
|
|
21
|
+
timeoutMs?: number | undefined;
|
|
22
|
+
terminateGraceMs?: number | undefined;
|
|
23
|
+
maxFrameBytes?: number | undefined;
|
|
24
|
+
environment?: NodeJS.ProcessEnv | undefined;
|
|
25
|
+
runtimeEnvironment?: NodeJS.ProcessEnv | undefined;
|
|
26
|
+
};
|
|
27
|
+
export type PiWorkerCompletion = {
|
|
28
|
+
ok: true;
|
|
29
|
+
result: PiRunResult;
|
|
30
|
+
outcome: FinalOutcomeContent;
|
|
31
|
+
feedback?: FeedbackRecord | undefined;
|
|
32
|
+
observabilityFeedback?: ObservabilityFeedbackRecord | undefined;
|
|
33
|
+
hasReply: boolean;
|
|
34
|
+
finishedAt: string;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
error: {
|
|
38
|
+
code: "runtime_spawn_failed" | "runtime_protocol_error" | "runtime_timeout" | "runtime_exit_failed" | "orphan_abandoned" | "worker_failed";
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
hasReply: boolean;
|
|
42
|
+
feedback?: FeedbackRecord | undefined;
|
|
43
|
+
observabilityFeedback?: ObservabilityFeedbackRecord | undefined;
|
|
44
|
+
finishedAt: string;
|
|
45
|
+
};
|
|
46
|
+
export type AgentRuntimeHandle = {
|
|
47
|
+
workerPid: number;
|
|
48
|
+
lifecycleDirectory: string;
|
|
49
|
+
runToken: string;
|
|
50
|
+
supervisorLeasePath: string;
|
|
51
|
+
orphanAfterMs: number;
|
|
52
|
+
};
|
|
53
|
+
export declare function lifecyclePaths(lifecycleDirectory: string): {
|
|
54
|
+
request: string;
|
|
55
|
+
completion: string;
|
|
56
|
+
heartbeat: string;
|
|
57
|
+
pid: string;
|
|
58
|
+
outcomeSocket: string;
|
|
59
|
+
};
|
|
60
|
+
export declare function prepareLifecycleDirectory(lifecycleDirectory: string): Promise<void>;
|
|
61
|
+
export declare function writeWorkerRequest(lifecycleDirectory: string, request: PiWorkerRequest): Promise<void>;
|
|
62
|
+
export declare function readWorkerRequest(lifecycleDirectory: string): Promise<PiWorkerRequest>;
|
|
63
|
+
export declare function writeWorkerHeartbeat(lifecycleDirectory: string): Promise<void>;
|
|
64
|
+
export declare function writeWorkerPid(lifecycleDirectory: string, pid: number): Promise<void>;
|
|
65
|
+
export declare function writeWorkerCompletion(lifecycleDirectory: string, completion: PiWorkerCompletion): Promise<void>;
|
|
66
|
+
export declare function readWorkerCompletion(lifecycleDirectory: string): Promise<PiWorkerCompletion | undefined>;
|
|
67
|
+
export declare function readWorkerHeartbeat(lifecycleDirectory: string): Promise<{
|
|
68
|
+
pid: number;
|
|
69
|
+
updatedAt: string;
|
|
70
|
+
} | undefined>;
|
|
71
|
+
export declare function readWorkerPid(lifecycleDirectory: string): Promise<number | undefined>;
|
|
72
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
73
|
+
export declare function isWorkerAdoptable(handle: Pick<AgentRuntimeHandle, "workerPid" | "lifecycleDirectory" | "orphanAfterMs">, nowMs?: number, heartbeatMaxAgeMs?: number): Promise<boolean>;
|
|
74
|
+
export declare function waitForWorkerCompletion(lifecycleDirectory: string, options?: {
|
|
75
|
+
pollIntervalMs?: number | undefined;
|
|
76
|
+
abortSignal?: AbortSignal | undefined;
|
|
77
|
+
}): Promise<PiWorkerCompletion>;
|
|
78
|
+
export declare function resolvePiWorkerMainPath(): string;
|
|
79
|
+
export type SpawnDetachedWorkerOptions = PiRuntimeOptions & {
|
|
80
|
+
beaconCliPath: string;
|
|
81
|
+
runToken: string;
|
|
82
|
+
supervisorLeasePath: string;
|
|
83
|
+
orphanAfterMs?: number | undefined;
|
|
84
|
+
workerMainPath?: string | undefined;
|
|
85
|
+
};
|
|
86
|
+
export declare function spawnDetachedPiWorker(lifecycleDirectory: string, request: Omit<PiWorkerRequest, "beaconCliPath" | "runToken" | "supervisorLeasePath" | "orphanAfterMs" | "executable" | "timeoutMs" | "terminateGraceMs" | "maxFrameBytes" | "environment" | "runtimeEnvironment"> & {
|
|
87
|
+
prompt: string;
|
|
88
|
+
playbook: string;
|
|
89
|
+
}, options: SpawnDetachedWorkerOptions): Promise<AgentRuntimeHandle>;
|
|
90
|
+
/** Stop a still-living worker (orphan / abandon). Best-effort. */
|
|
91
|
+
export declare function signalWorker(pid: number, signal?: NodeJS.Signals): void;
|
|
92
|
+
export declare function assertSupervisorOrThrow(supervisorLeasePath: string, orphanAfterMs: number): Promise<void>;
|