@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.
@@ -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 directory = await mkdtemp(join(tmpdir(), "beacon-outcomes-"));
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(directory, { recursive: true, force: true });
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
- export declare function submitOutcome(socketPath: string, runToken: string, outcome: OutcomePatch | FinalOutcomeContent): Promise<void>;
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
- export async function submitOutcome(socketPath, runToken, outcome) {
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 execute(triggerKey, input, runId) {
319
- const record = await this.options.store.update(triggerKey, (current) => {
320
- if (!current.run) {
321
- throw new Error(`Cannot execute Trigger ${triggerKey} without a Run`);
322
- }
323
- const session = current.run.sessionId && current.run.sessionPath
324
- ? {
325
- sessionId: current.run.sessionId,
326
- sessionPath: current.run.sessionPath,
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
- sessionId: current.run.runId,
330
- sessionPath: join(this.options.sessionDirectory, this.options.profile.id, current.run.runId),
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
- const systemPrompt = this.agentSystemPrompt(this.triggerFor(current, input), current.run.systemPrompt);
333
- return {
334
- ...current,
335
- run: {
336
- ...current.run,
337
- ...session,
338
- state: "starting",
339
- startedAt: this.timestamp(),
340
- systemPrompt,
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
+ }
@@ -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 outcomes = await startOutcomeServer();
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {