@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 +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/outcome/server.d.ts +2 -0
- package/dist/daemon/outcome/server.js +3 -0
- package/dist/daemon/run/orchestrator.js +48 -9
- package/dist/daemon/run/queue.d.ts +52 -5
- package/dist/daemon/run/queue.js +129 -27
- package/dist/daemon/runtime/pi-rpc.d.ts +17 -0
- package/dist/daemon/runtime/pi-rpc.js +51 -2
- 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
|
});
|
|
@@ -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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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",
|
|
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 {
|
|
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:
|