@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 +30 -1
- package/dist/daemon/config/global.d.ts +4 -0
- package/dist/daemon/config/global.js +32 -8
- package/dist/daemon/config/profile.d.ts +4 -0
- package/dist/daemon/config/profile.js +19 -0
- package/dist/daemon/doctor.js +3 -0
- package/dist/daemon/manual-trigger.js +2 -2
- package/dist/daemon/run/orchestrator.js +4 -4
- package/dist/daemon/run/queue.d.ts +52 -5
- package/dist/daemon/run/queue.js +129 -27
- package/dist/daemon/schedule-trigger.js +2 -2
- package/dist/daemon/service.js +2 -2
- package/examples/config.yaml +5 -1
- package/examples/profiles/example/profile.yaml +3 -0
- package/package.json +1 -1
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
|
|
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:
|
|
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
|
? {
|
package/dist/daemon/doctor.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
49
|
+
readonly defaultMaxConcurrent: number;
|
|
50
|
+
private readonly profileLimits;
|
|
51
|
+
private globalActive;
|
|
11
52
|
private readonly pending;
|
|
12
|
-
private readonly
|
|
13
|
-
constructor(
|
|
14
|
-
|
|
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
|
}
|
package/dist/daemon/run/queue.js
CHANGED
|
@@ -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
|
-
|
|
51
|
+
defaultMaxConcurrent;
|
|
52
|
+
profileLimits;
|
|
53
|
+
globalActive = 0;
|
|
5
54
|
pending = [];
|
|
6
|
-
|
|
7
|
-
constructor(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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({
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
this.
|
|
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
|
-
|
|
150
|
+
lane.active -= 1;
|
|
151
|
+
this.globalActive -= 1;
|
|
57
152
|
this.drain();
|
|
58
153
|
}
|
|
59
154
|
}
|
|
60
155
|
drain() {
|
|
61
|
-
this.
|
|
62
|
-
while (this.
|
|
63
|
-
const
|
|
64
|
-
|
|
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.
|
|
67
|
-
this.
|
|
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 {
|
|
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:
|
|
77
|
+
queue: createRunQueue(global.runs, [profile]),
|
|
78
78
|
outcomes,
|
|
79
79
|
delivery: createFeishuGateway(credentials),
|
|
80
80
|
});
|
package/dist/daemon/service.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
48
|
+
const queue = createRunQueue(global.runs, profiles);
|
|
49
49
|
let dashboard;
|
|
50
50
|
try {
|
|
51
51
|
if (global.dashboard.enabled) {
|
package/examples/config.yaml
CHANGED
|
@@ -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
|
-
|
|
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:
|