@nettee/beacon 0.4.9 → 0.5.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/README.md CHANGED
@@ -109,6 +109,34 @@ created by Beacon 0.1.2 and earlier; when omitted it defaults to `sessions/`
109
109
  beside `config.yaml`. When configured, it must be absolute. Beacon creates
110
110
  per-Run directories with mode `0700` when Pi starts.
111
111
 
112
+ Run admission splits host ceiling vs per-Profile budgets.
113
+ `runs.max_concurrent` in `config.yaml` is a **host-wide ceiling** on
114
+ simultaneously active Runs. Each Profile has its own concurrency budget and
115
+ queue lane (so one Profile cannot exhaust another's capacity). Set per-Profile
116
+ limits on that Profile's `profile.yaml`; omit them to use
117
+ `runs.default_max_concurrent` (default `1`) and `runs.max_queued`:
118
+
119
+ ```yaml
120
+ # ~/.beacon/config.yaml
121
+ runs:
122
+ max_concurrent: 3
123
+ max_queued: 100
124
+ default_max_concurrent: 1
125
+ timeout_seconds: 1800
126
+ terminate_grace_seconds: 5
127
+ ```
128
+
129
+ ```yaml
130
+ # ~/.beacon/profiles/alert-diagnoser/profile.yaml
131
+ max_concurrent: 2
132
+ # max_queued: 40 # optional; omit → runs.max_queued
133
+ ```
134
+
135
+ Per-Profile `max_concurrent` must not exceed the global ceiling (`beacon doctor`
136
+ and startup both reject that). Event Triggers still wait via
137
+ `enqueueWhenAvailable` instead of failing with `capacity_exceeded` when their
138
+ lane is full.
139
+
112
140
  Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, a missing complete `persona.md` + `task.md` pair, escaped Profile markdown, permissive secret or runtime-environment permissions, missing Profile credentials, and unknown Feishu channel names all fail startup. Beacon validates all Profiles before opening a Feishu connection.
113
141
 
114
142
  Group notify destinations are registered globally in `~/.beacon/channels.yaml`
@@ -306,7 +334,8 @@ pending dispatch without rematching historical events, and each recipient
306
334
  Profile is claimed once. Changing a listener affects only new events. Keep
307
335
  Profiles referenced by pending receipts configured until dispatch settles;
308
336
  removing one causes a visible fatal dispatch failure. Dispatch is serial and
309
- waits for the shared Run queue, while HTTP acceptance remains independent.
337
+ waits for that Profile's Run queue lane (under the host-wide active ceiling),
338
+ while HTTP acceptance remains independent.
310
339
  Active Runs interrupted by restart fail rather than execute twice; failed Runs
311
340
  are not retried automatically or retriggered by duplicate event delivery. Event
312
341
  records, like existing state records, have no automatic retention cleanup;
@@ -11,8 +11,12 @@ export type GlobalConfig = {
11
11
  sessionDirectory: string;
12
12
  };
13
13
  runs: {
14
+ /** Host-wide ceiling on simultaneously active Runs across all Profiles. */
14
15
  maxConcurrent: number;
16
+ /** Default per-Profile queue depth (each Profile has an isolated queue). */
15
17
  maxQueued: number;
18
+ /** Default per-Profile concurrency when Profile yaml omits `max_concurrent`. */
19
+ defaultMaxConcurrent: number;
16
20
  timeoutSeconds: number;
17
21
  terminateGraceSeconds: number;
18
22
  };
@@ -4,6 +4,36 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { parseStrictYaml } from "./yaml.js";
6
6
  const positiveInteger = z.number().int().positive().safe();
7
+ const nonNegativeInteger = z.number().int().nonnegative().safe();
8
+ const runsDocumentSchema = z
9
+ .object({
10
+ /** Host-wide ceiling on simultaneously active Runs across all Profiles. */
11
+ max_concurrent: positiveInteger,
12
+ /**
13
+ * Default per-Profile queue depth. Each Profile has its own queue so one
14
+ * Profile cannot exhaust another's capacity. Override on Profile yaml with
15
+ * `max_queued`.
16
+ */
17
+ max_queued: nonNegativeInteger,
18
+ /**
19
+ * Default per-Profile concurrency when Profile yaml omits `max_concurrent`.
20
+ * Defaults to 1.
21
+ */
22
+ default_max_concurrent: positiveInteger.optional(),
23
+ timeout_seconds: positiveInteger,
24
+ terminate_grace_seconds: positiveInteger,
25
+ })
26
+ .strict()
27
+ .superRefine((runs, context) => {
28
+ const defaultMax = runs.default_max_concurrent ?? 1;
29
+ if (defaultMax > runs.max_concurrent) {
30
+ context.addIssue({
31
+ code: "custom",
32
+ message: "runs.default_max_concurrent must be <= runs.max_concurrent (global ceiling)",
33
+ path: ["default_max_concurrent"],
34
+ });
35
+ }
36
+ });
7
37
  const globalDocumentSchema = z
8
38
  .object({
9
39
  version: z.literal(1),
@@ -15,14 +45,7 @@ const globalDocumentSchema = z
15
45
  session_directory: z.string().min(1).optional(),
16
46
  })
17
47
  .strict(),
18
- runs: z
19
- .object({
20
- max_concurrent: positiveInteger,
21
- max_queued: z.number().int().nonnegative().safe(),
22
- timeout_seconds: positiveInteger,
23
- terminate_grace_seconds: positiveInteger,
24
- })
25
- .strict(),
48
+ runs: runsDocumentSchema,
26
49
  scheduler: z
27
50
  .object({ max_occurrences_per_reconciliation: positiveInteger })
28
51
  .strict(),
@@ -112,6 +135,7 @@ export async function loadGlobalConfig(path) {
112
135
  runs: {
113
136
  maxConcurrent: document.runs.max_concurrent,
114
137
  maxQueued: document.runs.max_queued,
138
+ defaultMaxConcurrent: document.runs.default_max_concurrent ?? 1,
115
139
  timeoutSeconds: document.runs.timeout_seconds,
116
140
  terminateGraceSeconds: document.runs.terminate_grace_seconds,
117
141
  },
@@ -25,6 +25,10 @@ export type Profile = {
25
25
  provider: string;
26
26
  id: string;
27
27
  };
28
+ /** Per-Profile active Run limit from profile.yaml; omit → host default. */
29
+ maxConcurrent?: number | undefined;
30
+ /** Per-Profile queue depth from profile.yaml; omit → host `runs.max_queued`. */
31
+ maxQueued?: number | undefined;
28
32
  admin?: DeliveryDestination | undefined;
29
33
  listener?: EventListener | undefined;
30
34
  schedules: Array<{
@@ -61,6 +61,8 @@ const skillsSchema = z
61
61
  paths: z.array(z.string().trim().min(1)).default([]),
62
62
  })
63
63
  .strict();
64
+ const positiveInteger = z.number().int().positive().safe();
65
+ const nonNegativeInteger = z.number().int().nonnegative().safe();
64
66
  const profileDocumentSchema = z
65
67
  .object({
66
68
  playbook: z.string().min(1),
@@ -71,6 +73,17 @@ const profileDocumentSchema = z
71
73
  id: z.string().min(1),
72
74
  })
73
75
  .strict(),
76
+ /**
77
+ * Optional per-Profile active Run limit. When omitted, the host uses
78
+ * `runs.default_max_concurrent` from global config (default 1). Must not
79
+ * exceed global `runs.max_concurrent`.
80
+ */
81
+ max_concurrent: positiveInteger.optional(),
82
+ /**
83
+ * Optional per-Profile queue depth. When omitted, the host uses
84
+ * `runs.max_queued` from global config. Queues are isolated per Profile.
85
+ */
86
+ max_queued: nonNegativeInteger.optional(),
74
87
  admin: adminDestinationSchema.optional(),
75
88
  schedules: z.array(scheduleSchema).default([]),
76
89
  listener: z
@@ -293,6 +306,12 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
293
306
  playbook,
294
307
  runtime: config.runtime,
295
308
  model: config.model,
309
+ ...(config.max_concurrent !== undefined
310
+ ? { maxConcurrent: config.max_concurrent }
311
+ : {}),
312
+ ...(config.max_queued !== undefined
313
+ ? { maxQueued: config.max_queued }
314
+ : {}),
296
315
  ...(admin ? { admin } : {}),
297
316
  ...(config.listener
298
317
  ? {
@@ -5,12 +5,15 @@ import { loadProfileRegistry } from "./config/registry.js";
5
5
  import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
6
6
  import { loadFeishuCredentials } from "./config/secrets.js";
7
7
  import { createFeishuGateway } from "./feishu/gateway.js";
8
+ import { profileLimitsFromProfiles } from "./run/queue.js";
8
9
  import { runPiAgent } from "./runtime/pi-rpc.js";
9
10
  export async function runDoctor(configPath) {
10
11
  const global = await loadGlobalConfig(configPath);
11
12
  const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
12
13
  const channels = await loadChannelRegistry(global.channelsPath);
13
14
  const profiles = await loadProfileRegistry(global.profilesDirectory, channels, global.channelsPath);
15
+ // Fail fast if any Profile concurrency exceeds the host-wide ceiling.
16
+ profileLimitsFromProfiles(profiles, global.runs);
14
17
  if (global.events)
15
18
  await loadEventCredentials(global.events.credentialsPath);
16
19
  for (const profile of profiles) {
@@ -6,7 +6,7 @@ import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
6
6
  import { renderDeliveryAsText } from "./outcome/content.js";
7
7
  import { startOutcomeServer } from "./outcome/server.js";
8
8
  import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
9
- import { RunQueue } from "./run/queue.js";
9
+ import { createRunQueue } from "./run/queue.js";
10
10
  import { TriggerStore } from "./state/trigger-store.js";
11
11
  function writeStdout(text) {
12
12
  return new Promise((resolve, reject) => {
@@ -46,7 +46,7 @@ export async function runManualTrigger(configPath, profileId, input, options, ou
46
46
  runtimeEnvironment,
47
47
  profile,
48
48
  store,
49
- queue: new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued),
49
+ queue: createRunQueue(global.runs, [profile]),
50
50
  outcomes,
51
51
  delivery,
52
52
  });
@@ -8,6 +8,8 @@ export type OutcomeSubmission = {
8
8
  take(): FinalOutcomeContent;
9
9
  takeFeedback(): FeedbackRecord | undefined;
10
10
  takeObservabilityFeedback(): ObservabilityFeedbackRecord | undefined;
11
+ /** True when Agent already submitted reply_text / reply_card / no_reply. */
12
+ hasReply(): boolean;
11
13
  cancel(): void;
12
14
  };
13
15
  export type OutcomeSink = {
@@ -127,6 +127,9 @@ export async function startOutcomeServer(options = {}) {
127
127
  submittedAt: record.observabilityFeedbackSubmittedAt,
128
128
  };
129
129
  },
130
+ hasReply() {
131
+ return record.outcome?.reply !== undefined;
132
+ },
130
133
  cancel() {
131
134
  submissions.delete(runToken);
132
135
  },
@@ -2,7 +2,7 @@ import { 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
- import { PiRuntimeError } from "../runtime/pi-rpc.js";
5
+ import { PiRuntimeError, RUNTIME_TIMEOUT_OUTCOME_MISSING_NOTE, RUNTIME_TIMEOUT_OUTCOME_SUBMITTED_NOTE, } from "../runtime/pi-rpc.js";
6
6
  import { agentSystemPromptTrigger, buildAgentSystemPrompt, profilePromptDigest, } from "../runtime/system-prompt.js";
7
7
  function promptFor(input) {
8
8
  if (input.kind === "event") {
@@ -79,6 +79,34 @@ function detailBeyondMissingSummary(summaryText) {
79
79
  }
80
80
  return clipForFeishu(summaryText, FAILURE_DETAIL_LIMIT);
81
81
  }
82
+ function formatRuntimeTimeoutReplyText(runId, summaryText) {
83
+ const duration = summaryText.match(/after\s+([^:]+?):\s*Pi process/i)?.[1]?.trim() ??
84
+ summaryText
85
+ .match(/after\s+(\d+min \(\d+ms\)|\d+s \(\d+ms\)|\d+ms)/i)?.[1]
86
+ ?.trim() ??
87
+ "configured limit";
88
+ const parts = [
89
+ `处理失败(run_id=${runId},code=runtime_timeout):`,
90
+ `Beacon Run 硬超时(${duration}):Pi 进程未在限时内退出。`,
91
+ ];
92
+ if (/Final Outcome was already submitted before timeout/i.test(summaryText)) {
93
+ parts.push("超时前 Agent 已提交 Final Outcome,但仍因 Pi 未退出被超时失败路径覆盖。");
94
+ }
95
+ else if (/No Final Outcome was submitted before timeout/i.test(summaryText)) {
96
+ parts.push("超时前未提交 Final Outcome。");
97
+ }
98
+ const remainingRaw = summaryText.match(/Remaining stderr:\s*([\s\S]+)/i)?.[1];
99
+ if (remainingRaw) {
100
+ const remaining = remainingRaw
101
+ .replace(/\.\s*Final Outcome was already submitted[\s\S]*$/i, "")
102
+ .replace(/\.\s*No Final Outcome was submitted[\s\S]*$/i, "")
103
+ .trim();
104
+ if (remaining) {
105
+ parts.push(`附加诊断:${clipForFeishu(remaining, FAILURE_DETAIL_LIMIT)}`);
106
+ }
107
+ }
108
+ return parts.join("");
109
+ }
82
110
  /** User-visible (Feishu/admin) failure text. Keep codes stable; prefer concrete reasons. */
83
111
  export function formatFailureReplyText(runId, failure) {
84
112
  if (failure?.code === "outcome_missing") {
@@ -93,6 +121,9 @@ export function formatFailureReplyText(runId, failure) {
93
121
  parts.push(`详情:${detail}`);
94
122
  return parts.join("");
95
123
  }
124
+ if (failure?.code === "runtime_timeout" && failure.summary?.trim()) {
125
+ return formatRuntimeTimeoutReplyText(runId, failure.summary);
126
+ }
96
127
  if (failure?.summary?.trim()) {
97
128
  return `处理失败(run_id=${runId},code=${failure.code}):${clipForFeishu(failure.summary, FAILURE_DETAIL_LIMIT)}`;
98
129
  }
@@ -365,13 +396,21 @@ export class RunOrchestrator {
365
396
  catch (error) {
366
397
  const submitted = submission.takeFeedback();
367
398
  const submittedObservability = submission.takeObservabilityFeedback();
399
+ const replySubmitted = submission.hasReply();
368
400
  submission.cancel();
369
- const code = error instanceof PiRuntimeError
370
- ? error.code
371
- : isMissingReplyOutcomeError(error)
401
+ let failError = error;
402
+ if (error instanceof PiRuntimeError && error.code === "runtime_timeout") {
403
+ const note = replySubmitted
404
+ ? RUNTIME_TIMEOUT_OUTCOME_SUBMITTED_NOTE
405
+ : RUNTIME_TIMEOUT_OUTCOME_MISSING_NOTE;
406
+ failError = new PiRuntimeError("runtime_timeout", `${error.message}. ${note}`);
407
+ }
408
+ const code = failError instanceof PiRuntimeError
409
+ ? failError.code
410
+ : isMissingReplyOutcomeError(failError)
372
411
  ? "outcome_missing"
373
412
  : "runtime_exit_failed";
374
- await this.fail(triggerKey, runId, code, error, submitted, submittedObservability, settle);
413
+ await this.fail(triggerKey, runId, code, failError, submitted, submittedObservability, settle);
375
414
  }
376
415
  }
377
416
  async process(triggerKey, normalize) {
@@ -409,10 +448,10 @@ export class RunOrchestrator {
409
448
  run: this.newRun(runId, "queued", this.triggerFor(current, input), undefined, continued),
410
449
  }));
411
450
  if (input.kind === "event") {
412
- await this.options.queue.enqueueWhenAvailable(() => this.execute(triggerKey, input, runId));
451
+ await this.options.queue.enqueueWhenAvailable(this.options.profile.id, () => this.execute(triggerKey, input, runId));
413
452
  return;
414
453
  }
415
- const queued = this.options.queue.enqueue(() => this.execute(triggerKey, input, runId));
454
+ const queued = this.options.queue.enqueue(this.options.profile.id, () => this.execute(triggerKey, input, runId));
416
455
  if (!queued.accepted) {
417
456
  await this.fail(triggerKey, runId, "capacity_exceeded", "Run queue capacity exceeded");
418
457
  return;
@@ -449,10 +488,10 @@ export class RunOrchestrator {
449
488
  }
450
489
  if (latest.run?.state === "queued" && latest.input) {
451
490
  if (latest.input.kind === "event") {
452
- await this.options.queue.enqueueWhenAvailable(() => this.execute(latest.triggerKey, latest.input, latest.run.runId));
491
+ await this.options.queue.enqueueWhenAvailable(this.options.profile.id, () => this.execute(latest.triggerKey, latest.input, latest.run.runId));
453
492
  continue;
454
493
  }
455
- const queued = this.options.queue.enqueue(() => this.execute(latest.triggerKey, latest.input, latest.run.runId));
494
+ const queued = this.options.queue.enqueue(this.options.profile.id, () => this.execute(latest.triggerKey, latest.input, latest.run.runId));
456
495
  if (!queued.accepted) {
457
496
  throw new Error("Persisted queued Runs exceed configured queue capacity");
458
497
  }
@@ -4,17 +4,64 @@ export type QueueSubmission = {
4
4
  accepted: true;
5
5
  completion: Promise<void>;
6
6
  };
7
+ export type ProfileRunLimits = {
8
+ maxConcurrent: number;
9
+ maxQueued?: number | undefined;
10
+ };
11
+ export type RunQueueOptions = {
12
+ /** Global ceiling on simultaneously active runs across all profiles. */
13
+ maxConcurrent: number;
14
+ /** Default per-profile queue depth when a profile does not override max_queued. */
15
+ maxQueued: number;
16
+ /**
17
+ * Default per-profile concurrency when Profile yaml omits `max_concurrent`.
18
+ * Defaults to 1.
19
+ */
20
+ defaultMaxConcurrent?: number | undefined;
21
+ /** Per-profile limits from Profile yaml, keyed by Profile ID. */
22
+ profiles?: Readonly<Record<string, ProfileRunLimits>> | undefined;
23
+ };
24
+ export type ProfileRunLimitSource = {
25
+ id: string;
26
+ maxConcurrent?: number | undefined;
27
+ maxQueued?: number | undefined;
28
+ };
29
+ export type RunAdmissionDefaults = {
30
+ maxConcurrent: number;
31
+ maxQueued: number;
32
+ defaultMaxConcurrent: number;
33
+ };
34
+ /**
35
+ * Build per-profile queue limits from loaded Profiles and reject any Profile
36
+ * whose effective concurrency exceeds the host-wide ceiling.
37
+ */
38
+ export declare function profileLimitsFromProfiles(profiles: readonly ProfileRunLimitSource[], defaults: Pick<RunAdmissionDefaults, "maxConcurrent" | "defaultMaxConcurrent">): Record<string, ProfileRunLimits>;
39
+ /** Construct a RunQueue from global runs defaults + loaded Profiles. */
40
+ export declare function createRunQueue(defaults: RunAdmissionDefaults, profiles: readonly ProfileRunLimitSource[]): RunQueue;
41
+ /**
42
+ * Multi-lane Run admission: each Profile has its own concurrency and queue
43
+ * budget so one Profile cannot exhaust another's slots. `maxConcurrent` is a
44
+ * host-wide ceiling across all active Runs.
45
+ */
7
46
  export declare class RunQueue {
8
47
  readonly maxConcurrent: number;
9
48
  readonly maxQueued: number;
10
- private active;
49
+ readonly defaultMaxConcurrent: number;
50
+ private readonly profileLimits;
51
+ private globalActive;
11
52
  private readonly pending;
12
- private readonly waiting;
13
- constructor(maxConcurrent: number, maxQueued: number);
14
- enqueue(task: () => Promise<void>): QueueSubmission;
53
+ private readonly lanes;
54
+ constructor(options: RunQueueOptions);
55
+ limitsFor(profileId: string): {
56
+ maxConcurrent: number;
57
+ maxQueued: number;
58
+ };
59
+ enqueue(profileId: string, task: () => Promise<void>): QueueSubmission;
15
60
  /** Wait for admission without dropping already accepted, persisted event work. */
16
- enqueueWhenAvailable(task: () => Promise<void>): Promise<void>;
61
+ enqueueWhenAvailable(profileId: string, task: () => Promise<void>): Promise<void>;
62
+ private lane;
17
63
  private promoteWaiting;
64
+ private promoteAllWaiting;
18
65
  private execute;
19
66
  private drain;
20
67
  }
@@ -1,22 +1,92 @@
1
+ /**
2
+ * Build per-profile queue limits from loaded Profiles and reject any Profile
3
+ * whose effective concurrency exceeds the host-wide ceiling.
4
+ */
5
+ export function profileLimitsFromProfiles(profiles, defaults) {
6
+ const limits = {};
7
+ for (const profile of profiles) {
8
+ const maxConcurrent = profile.maxConcurrent ?? defaults.defaultMaxConcurrent;
9
+ if (maxConcurrent > defaults.maxConcurrent) {
10
+ throw new Error(`Profile ${profile.id} max_concurrent (${String(maxConcurrent)}) exceeds runs.max_concurrent (${String(defaults.maxConcurrent)})`);
11
+ }
12
+ if (profile.maxConcurrent !== undefined ||
13
+ profile.maxQueued !== undefined) {
14
+ limits[profile.id] = {
15
+ maxConcurrent,
16
+ ...(profile.maxQueued !== undefined
17
+ ? { maxQueued: profile.maxQueued }
18
+ : {}),
19
+ };
20
+ }
21
+ }
22
+ return limits;
23
+ }
24
+ /** Construct a RunQueue from global runs defaults + loaded Profiles. */
25
+ export function createRunQueue(defaults, profiles) {
26
+ return new RunQueue({
27
+ maxConcurrent: defaults.maxConcurrent,
28
+ maxQueued: defaults.maxQueued,
29
+ defaultMaxConcurrent: defaults.defaultMaxConcurrent,
30
+ profiles: profileLimitsFromProfiles(profiles, defaults),
31
+ });
32
+ }
33
+ function assertPositiveInteger(name, value) {
34
+ if (!Number.isSafeInteger(value) || value <= 0) {
35
+ throw new Error(`${name} must be a positive integer`);
36
+ }
37
+ }
38
+ function assertNonNegativeInteger(name, value) {
39
+ if (!Number.isSafeInteger(value) || value < 0) {
40
+ throw new Error(`${name} must be a non-negative integer`);
41
+ }
42
+ }
43
+ /**
44
+ * Multi-lane Run admission: each Profile has its own concurrency and queue
45
+ * budget so one Profile cannot exhaust another's slots. `maxConcurrent` is a
46
+ * host-wide ceiling across all active Runs.
47
+ */
1
48
  export class RunQueue {
2
49
  maxConcurrent;
3
50
  maxQueued;
4
- active = 0;
51
+ defaultMaxConcurrent;
52
+ profileLimits;
53
+ globalActive = 0;
5
54
  pending = [];
6
- waiting = [];
7
- constructor(maxConcurrent, maxQueued) {
8
- this.maxConcurrent = maxConcurrent;
9
- this.maxQueued = maxQueued;
10
- if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent <= 0) {
11
- throw new Error("maxConcurrent must be a positive integer");
55
+ lanes = new Map();
56
+ constructor(options) {
57
+ assertPositiveInteger("maxConcurrent", options.maxConcurrent);
58
+ assertNonNegativeInteger("maxQueued", options.maxQueued);
59
+ const defaultMaxConcurrent = options.defaultMaxConcurrent ?? 1;
60
+ assertPositiveInteger("defaultMaxConcurrent", defaultMaxConcurrent);
61
+ if (defaultMaxConcurrent > options.maxConcurrent) {
62
+ throw new Error("defaultMaxConcurrent must be <= maxConcurrent (global ceiling)");
12
63
  }
13
- if (!Number.isSafeInteger(maxQueued) || maxQueued < 0) {
14
- throw new Error("maxQueued must be a non-negative integer");
64
+ for (const [profileId, limits] of Object.entries(options.profiles ?? {})) {
65
+ assertPositiveInteger(`profiles.${profileId}.maxConcurrent`, limits.maxConcurrent);
66
+ if (limits.maxConcurrent > options.maxConcurrent) {
67
+ throw new Error(`profiles.${profileId}.maxConcurrent must be <= maxConcurrent (global ceiling)`);
68
+ }
69
+ if (limits.maxQueued !== undefined) {
70
+ assertNonNegativeInteger(`profiles.${profileId}.maxQueued`, limits.maxQueued);
71
+ }
15
72
  }
73
+ this.maxConcurrent = options.maxConcurrent;
74
+ this.maxQueued = options.maxQueued;
75
+ this.defaultMaxConcurrent = defaultMaxConcurrent;
76
+ this.profileLimits = options.profiles ?? {};
16
77
  }
17
- enqueue(task) {
18
- if (this.active >= this.maxConcurrent &&
19
- this.pending.length >= this.maxQueued) {
78
+ limitsFor(profileId) {
79
+ const override = this.profileLimits[profileId];
80
+ return {
81
+ maxConcurrent: override?.maxConcurrent ?? this.defaultMaxConcurrent,
82
+ maxQueued: override?.maxQueued ?? this.maxQueued,
83
+ };
84
+ }
85
+ enqueue(profileId, task) {
86
+ const limits = this.limitsFor(profileId);
87
+ const lane = this.lane(profileId);
88
+ if (lane.active >= limits.maxConcurrent &&
89
+ lane.queued >= limits.maxQueued) {
20
90
  return { accepted: false };
21
91
  }
22
92
  let resolve;
@@ -25,26 +95,50 @@ export class RunQueue {
25
95
  resolve = resolvePromise;
26
96
  reject = rejectPromise;
27
97
  });
28
- this.pending.push({ task, resolve, reject });
98
+ this.pending.push({ profileId, task, resolve, reject });
99
+ lane.queued += 1;
29
100
  this.drain();
30
101
  return { accepted: true, completion };
31
102
  }
32
103
  /** Wait for admission without dropping already accepted, persisted event work. */
33
- enqueueWhenAvailable(task) {
104
+ enqueueWhenAvailable(profileId, task) {
34
105
  const completion = new Promise((resolve, reject) => {
35
- this.waiting.push({ task, resolve, reject });
106
+ this.lane(profileId).waiting.push({
107
+ profileId,
108
+ task,
109
+ resolve,
110
+ reject,
111
+ });
36
112
  });
37
113
  this.drain();
38
114
  return completion;
39
115
  }
40
- promoteWaiting() {
41
- while (this.waiting.length > 0 &&
42
- this.pending.length <
43
- this.maxQueued + Math.max(0, this.maxConcurrent - this.active)) {
44
- this.pending.push(this.waiting.shift());
116
+ lane(profileId) {
117
+ let lane = this.lanes.get(profileId);
118
+ if (!lane) {
119
+ lane = { active: 0, queued: 0, waiting: [] };
120
+ this.lanes.set(profileId, lane);
121
+ }
122
+ return lane;
123
+ }
124
+ promoteWaiting(profileId) {
125
+ const limits = this.limitsFor(profileId);
126
+ const lane = this.lane(profileId);
127
+ while (lane.waiting.length > 0 &&
128
+ lane.queued <
129
+ limits.maxQueued + Math.max(0, limits.maxConcurrent - lane.active)) {
130
+ const next = lane.waiting.shift();
131
+ this.pending.push(next);
132
+ lane.queued += 1;
133
+ }
134
+ }
135
+ promoteAllWaiting() {
136
+ for (const profileId of this.lanes.keys()) {
137
+ this.promoteWaiting(profileId);
45
138
  }
46
139
  }
47
140
  async execute(next) {
141
+ const lane = this.lane(next.profileId);
48
142
  try {
49
143
  await next.task();
50
144
  next.resolve();
@@ -53,18 +147,26 @@ export class RunQueue {
53
147
  next.reject(error);
54
148
  }
55
149
  finally {
56
- this.active -= 1;
150
+ lane.active -= 1;
151
+ this.globalActive -= 1;
57
152
  this.drain();
58
153
  }
59
154
  }
60
155
  drain() {
61
- this.promoteWaiting();
62
- while (this.active < this.maxConcurrent) {
63
- const next = this.pending.shift();
64
- if (!next)
156
+ this.promoteAllWaiting();
157
+ while (this.globalActive < this.maxConcurrent) {
158
+ const index = this.pending.findIndex((entry) => {
159
+ const limits = this.limitsFor(entry.profileId);
160
+ return this.lane(entry.profileId).active < limits.maxConcurrent;
161
+ });
162
+ if (index < 0)
65
163
  return;
66
- this.active += 1;
67
- this.promoteWaiting();
164
+ const next = this.pending.splice(index, 1)[0];
165
+ const lane = this.lane(next.profileId);
166
+ lane.queued -= 1;
167
+ lane.active += 1;
168
+ this.globalActive += 1;
169
+ this.promoteWaiting(next.profileId);
68
170
  void this.execute(next);
69
171
  }
70
172
  }
@@ -43,6 +43,23 @@ export declare class PiRuntimeError extends Error {
43
43
  export declare function collectThinking(content: unknown[]): string;
44
44
  /** Exported for unit tests that assert argv without spawning a real Pi. */
45
45
  export declare function buildArguments(request: PiRunRequest): string[];
46
+ /**
47
+ * Drop known-benign Pi stderr lines (e.g. "No project session found… creating
48
+ * a new session") so they are not pasted into failure summaries as if they were
49
+ * the root cause.
50
+ */
51
+ export declare function sanitizePiStderr(stderr: string): string;
52
+ /** Human-readable timeout budget for operator-facing messages. */
53
+ export declare function formatTimeoutDuration(timeoutMs: number): string;
54
+ /** Stable English note when Outcome was submitted before hard timeout. */
55
+ export declare const RUNTIME_TIMEOUT_OUTCOME_SUBMITTED_NOTE = "Final Outcome was already submitted before timeout; Beacon still marked the Run failed because the Pi process did not exit in time";
56
+ /** Stable English note when Outcome was missing at hard timeout. */
57
+ export declare const RUNTIME_TIMEOUT_OUTCOME_MISSING_NOTE = "No Final Outcome was submitted before timeout";
58
+ /**
59
+ * Beacon-owned hard timeout summary. Makes clear this is Beacon's Run limit,
60
+ * not a Pi "session not found" failure.
61
+ */
62
+ export declare function formatRuntimeTimeoutMessage(timeoutMs: number, stderr: string): string;
46
63
  /**
47
64
  * Read NDJSON frames from Pi stdout with a hard per-frame byte cap.
48
65
  *
@@ -142,8 +142,57 @@ function buildPiEnvironment(outcome, configured = {}, runtimeEnvironment = {}) {
142
142
  }
143
143
  return environment;
144
144
  }
145
+ /** Pi stderr that is normal session bootstrap, not a failure cause. */
146
+ const BENIGN_PI_STDERR_PATTERNS = [
147
+ /^Warning:\s*No project session found with id '[^']+'; creating a new session with that id\.?\s*$/i,
148
+ ];
149
+ /**
150
+ * Drop known-benign Pi stderr lines (e.g. "No project session found… creating
151
+ * a new session") so they are not pasted into failure summaries as if they were
152
+ * the root cause.
153
+ */
154
+ export function sanitizePiStderr(stderr) {
155
+ return stderr
156
+ .split(/\r?\n/)
157
+ .map((line) => line.trimEnd())
158
+ .filter((line) => {
159
+ const trimmed = line.trim();
160
+ if (!trimmed)
161
+ return false;
162
+ return !BENIGN_PI_STDERR_PATTERNS.some((pattern) => pattern.test(trimmed));
163
+ })
164
+ .join("\n")
165
+ .trim();
166
+ }
167
+ /** Human-readable timeout budget for operator-facing messages. */
168
+ export function formatTimeoutDuration(timeoutMs) {
169
+ if (Number.isSafeInteger(timeoutMs) &&
170
+ timeoutMs > 0 &&
171
+ timeoutMs % 60_000 === 0) {
172
+ return `${timeoutMs / 60_000}min (${timeoutMs}ms)`;
173
+ }
174
+ if (Number.isSafeInteger(timeoutMs) &&
175
+ timeoutMs > 0 &&
176
+ timeoutMs % 1_000 === 0) {
177
+ return `${timeoutMs / 1_000}s (${timeoutMs}ms)`;
178
+ }
179
+ return `${timeoutMs}ms`;
180
+ }
181
+ /** Stable English note when Outcome was submitted before hard timeout. */
182
+ export const RUNTIME_TIMEOUT_OUTCOME_SUBMITTED_NOTE = "Final Outcome was already submitted before timeout; Beacon still marked the Run failed because the Pi process did not exit in time";
183
+ /** Stable English note when Outcome was missing at hard timeout. */
184
+ export const RUNTIME_TIMEOUT_OUTCOME_MISSING_NOTE = "No Final Outcome was submitted before timeout";
185
+ /**
186
+ * Beacon-owned hard timeout summary. Makes clear this is Beacon's Run limit,
187
+ * not a Pi "session not found" failure.
188
+ */
189
+ export function formatRuntimeTimeoutMessage(timeoutMs, stderr) {
190
+ const duration = formatTimeoutDuration(timeoutMs);
191
+ const detail = sanitizePiStderr(stderr);
192
+ return `Beacon Run hard timeout after ${duration}: Pi process did not exit in time${detail ? `. Remaining stderr: ${detail}` : ""}`;
193
+ }
145
194
  function describeExit(code, signal, stderr) {
146
- const detail = stderr.trim();
195
+ const detail = sanitizePiStderr(stderr);
147
196
  return new PiRuntimeError("runtime_exit_failed", `Pi process exited before the Run settled (code=${String(code)} signal=${String(signal)})${detail ? `: ${detail}` : ""}`);
148
197
  }
149
198
  async function stopProcess(child, terminateGraceMs) {
@@ -298,7 +347,7 @@ export async function runPiAgent(request, options = {}) {
298
347
  clearTimeout(timer);
299
348
  reject(error);
300
349
  };
301
- const timer = setTimeout(() => fail(new PiRuntimeError("runtime_timeout", `Pi Run timed out after ${timeoutMs}ms${stderr.trim() ? `: ${stderr.trim()}` : ""}`)), timeoutMs);
350
+ const timer = setTimeout(() => fail(new PiRuntimeError("runtime_timeout", formatRuntimeTimeoutMessage(timeoutMs, stderr))), timeoutMs);
302
351
  const handleFrame = (line) => {
303
352
  if (finished)
304
353
  return;
@@ -8,7 +8,7 @@ import { loadFeishuCredentials } from "./config/secrets.js";
8
8
  import { createFeishuGateway } from "./feishu/gateway.js";
9
9
  import { startOutcomeServer } from "./outcome/server.js";
10
10
  import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
11
- import { RunQueue } from "./run/queue.js";
11
+ import { createRunQueue } from "./run/queue.js";
12
12
  import { TriggerStore } from "./state/trigger-store.js";
13
13
  function findSchedule(profile, scheduleId) {
14
14
  const schedule = profile.schedules.find((candidate) => candidate.id === scheduleId);
@@ -74,7 +74,7 @@ export async function runScheduleTrigger(configPath, profileId, scheduleId, opti
74
74
  runtimeEnvironment,
75
75
  profile,
76
76
  store,
77
- queue: new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued),
77
+ queue: createRunQueue(global.runs, [profile]),
78
78
  outcomes,
79
79
  delivery: createFeishuGateway(credentials),
80
80
  });
@@ -13,7 +13,7 @@ import { createFeishuGateway } from "./feishu/gateway.js";
13
13
  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
- import { RunQueue } from "./run/queue.js";
16
+ import { createRunQueue } from "./run/queue.js";
17
17
  import { ScheduleCursorStore } from "./schedule/cursor-store.js";
18
18
  import { ScheduleLoop } from "./schedule/loop.js";
19
19
  import { ScheduleReconciler } from "./schedule/reconciler.js";
@@ -45,7 +45,7 @@ export async function runBeacon(configPath, options) {
45
45
  ? await loadEventCredentials(global.events.credentialsPath)
46
46
  : [];
47
47
  const outcomes = await startOutcomeServer();
48
- const queue = new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued);
48
+ const queue = createRunQueue(global.runs, profiles);
49
49
  let dashboard;
50
50
  try {
51
51
  if (global.dashboard.enabled) {
@@ -5,8 +5,12 @@ pi:
5
5
  coding_agent_directory: /Users/USERNAME/.pi/agent
6
6
  session_directory: /Users/USERNAME/.beacon/sessions
7
7
  runs:
8
- max_concurrent: 2
8
+ # Host-wide ceiling across all Profiles (active Runs only).
9
+ max_concurrent: 3
10
+ # Default per-Profile queue depth (queues are isolated per Profile).
9
11
  max_queued: 100
12
+ # Default per-Profile concurrency when profile.yaml omits max_concurrent.
13
+ default_max_concurrent: 1
10
14
  timeout_seconds: 1800
11
15
  terminate_grace_seconds: 5
12
16
  scheduler:
@@ -3,6 +3,9 @@ runtime: pi
3
3
  model:
4
4
  provider: REPLACE_WITH_PI_PROVIDER
5
5
  id: REPLACE_WITH_PI_MODEL
6
+ # Optional Run admission (omit → host runs.default_max_concurrent / max_queued).
7
+ # max_concurrent: 1
8
+ # max_queued: 20
6
9
  admin:
7
10
  chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
8
11
  schedules:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {