@nettee/beacon 0.4.10 → 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
  });
@@ -448,10 +448,10 @@ export class RunOrchestrator {
448
448
  run: this.newRun(runId, "queued", this.triggerFor(current, input), undefined, continued),
449
449
  }));
450
450
  if (input.kind === "event") {
451
- 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));
452
452
  return;
453
453
  }
454
- 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));
455
455
  if (!queued.accepted) {
456
456
  await this.fail(triggerKey, runId, "capacity_exceeded", "Run queue capacity exceeded");
457
457
  return;
@@ -488,10 +488,10 @@ export class RunOrchestrator {
488
488
  }
489
489
  if (latest.run?.state === "queued" && latest.input) {
490
490
  if (latest.input.kind === "event") {
491
- 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));
492
492
  continue;
493
493
  }
494
- 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));
495
495
  if (!queued.accepted) {
496
496
  throw new Error("Persisted queued Runs exceed configured queue capacity");
497
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
  }
@@ -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.10",
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": {