@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.
@@ -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 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,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 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 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>;