ework-daemon 0.4.64 → 0.4.65
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/package.json +1 -1
- package/src/config.ts +12 -0
- package/src/opencode.ts +55 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -74,6 +74,10 @@ export const configSchema = z.object({
|
|
|
74
74
|
maxNudges: z.coerce.number().int().nonnegative(),
|
|
75
75
|
maxRuntimeMs: z.coerce.number().positive(),
|
|
76
76
|
}).optional(),
|
|
77
|
+
replyBurst: z.object({
|
|
78
|
+
max: z.coerce.number().int().positive(),
|
|
79
|
+
windowMs: z.coerce.number().positive(),
|
|
80
|
+
}).optional(),
|
|
77
81
|
file: z.object({
|
|
78
82
|
roots: z.array(z.string()).default([]),
|
|
79
83
|
maxLines: z.coerce.number().int().positive().default(2000),
|
|
@@ -176,6 +180,10 @@ export function loadConfig(): Config {
|
|
|
176
180
|
maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
|
|
177
181
|
maxRuntimeMs: Number(process.env.DAEMON_STUCK_MAX_RUNTIME_MS) || 3 * 60 * 60 * 1000,
|
|
178
182
|
} : undefined,
|
|
183
|
+
replyBurst: process.env.WORK_REPLY_BURST_MAX || process.env.WORK_REPLY_BURST_WINDOW_MS ? {
|
|
184
|
+
max: Number(process.env.WORK_REPLY_BURST_MAX) || 8,
|
|
185
|
+
windowMs: Number(process.env.WORK_REPLY_BURST_WINDOW_MS) || 5 * 60 * 1000,
|
|
186
|
+
} : undefined,
|
|
179
187
|
childEnvDeny: (process.env.WORK_CHILD_ENV_DENY ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
180
188
|
});
|
|
181
189
|
}
|
|
@@ -224,6 +232,10 @@ export function loadConfig(): Config {
|
|
|
224
232
|
maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
|
|
225
233
|
maxRuntimeMs: Number(process.env.DAEMON_STUCK_MAX_RUNTIME_MS) || 3 * 60 * 60 * 1000,
|
|
226
234
|
} : undefined,
|
|
235
|
+
replyBurst: process.env.WORK_REPLY_BURST_MAX || process.env.WORK_REPLY_BURST_WINDOW_MS ? {
|
|
236
|
+
max: Number(process.env.WORK_REPLY_BURST_MAX) || 8,
|
|
237
|
+
windowMs: Number(process.env.WORK_REPLY_BURST_WINDOW_MS) || 5 * 60 * 1000,
|
|
238
|
+
} : undefined,
|
|
227
239
|
file: {
|
|
228
240
|
roots: (process.env.WORK_FILE_ROOTS ?? "").split(":").filter(Boolean).length > 0
|
|
229
241
|
? (process.env.WORK_FILE_ROOTS ?? "").split(":").filter(Boolean)
|
package/src/opencode.ts
CHANGED
|
@@ -301,6 +301,7 @@ export interface EngineOptions {
|
|
|
301
301
|
takeover?: TakeoverStrategy;
|
|
302
302
|
backend?: RuntimeBackend;
|
|
303
303
|
gateChecker?: (issue: Issue) => Promise<{ allowed: boolean; reason: string }>;
|
|
304
|
+
replyBurst?: { max: number; windowMs: number };
|
|
304
305
|
}
|
|
305
306
|
|
|
306
307
|
function createDefaultBackend(cfg: Config): RuntimeBackend {
|
|
@@ -331,6 +332,19 @@ export function wakePolicySkips(
|
|
|
331
332
|
return null;
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
// Reply-burst circuit breaker state: prune timestamps to the sliding window,
|
|
336
|
+
// trip when the retained count reaches max. Pure for testability.
|
|
337
|
+
export function replyBurstState(
|
|
338
|
+
stamps: number[],
|
|
339
|
+
now: number,
|
|
340
|
+
max: number,
|
|
341
|
+
windowMs: number,
|
|
342
|
+
): { tripped: boolean; kept: number[] } {
|
|
343
|
+
const kept = stamps.filter((t) => now - t < windowMs);
|
|
344
|
+
kept.push(now);
|
|
345
|
+
return { tripped: kept.length >= max, kept };
|
|
346
|
+
}
|
|
347
|
+
|
|
334
348
|
// Per-issue npm prefix: `npm install -g <pkg>` inside a session lands in the issue's
|
|
335
349
|
// workdir instead of the system global, so concurrent agents debugging different
|
|
336
350
|
// issues cannot clobber each other's global installs (nor poison the shared daemon env).
|
|
@@ -398,6 +412,8 @@ export class Engine {
|
|
|
398
412
|
private altBackend?: RuntimeBackend;
|
|
399
413
|
private senders = new Map<string, string>();
|
|
400
414
|
private envInitialized = new Set<string>();
|
|
415
|
+
private replyBurstCfg?: { max: number; windowMs: number };
|
|
416
|
+
private replyStamps = new Map<string, number[]>();
|
|
401
417
|
|
|
402
418
|
private static MAX_INLINE_SIZE = 4000;
|
|
403
419
|
private static MAX_NUDGE_ROUNDS = 1;
|
|
@@ -406,6 +422,8 @@ export class Engine {
|
|
|
406
422
|
private static MAX_RUNTIME_MS = 3 * 60 * 60 * 1000;
|
|
407
423
|
private static OBSERVER_INTERVAL_MS = 5 * 60 * 1000;
|
|
408
424
|
private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
|
|
425
|
+
private static MAX_REPLY_BURST = 8;
|
|
426
|
+
private static REPLY_BURST_WINDOW_MS = 5 * 60 * 1000;
|
|
409
427
|
private static MAX_PROCESS_EXIT_NUDGE_ROUNDS = 1;
|
|
410
428
|
|
|
411
429
|
constructor(cfg: Config, store: Store, trackers: TrackerRegistry, opts: EngineOptions) {
|
|
@@ -416,6 +434,7 @@ export class Engine {
|
|
|
416
434
|
this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
|
|
417
435
|
this.backend = opts.backend ?? createDefaultBackend(cfg);
|
|
418
436
|
this.gateChecker = opts.gateChecker ?? ((issue: Issue) => this.webGateAllows(issue));
|
|
437
|
+
this.replyBurstCfg = opts.replyBurst ?? cfg.replyBurst;
|
|
419
438
|
this.maxConcurrent = cfg.work.maxConcurrent;
|
|
420
439
|
this.maxConcurrentExplicit = cfg.work.maxConcurrentExplicit;
|
|
421
440
|
this.startGlobalObserver();
|
|
@@ -783,6 +802,13 @@ export class Engine {
|
|
|
783
802
|
return;
|
|
784
803
|
}
|
|
785
804
|
|
|
805
|
+
// Self-authored comment (our own reply landing back): never wake on it,
|
|
806
|
+
// but feed the reply-burst circuit breaker first.
|
|
807
|
+
if (event.type === "comment_created" && event.comment?.author === this.cfg.bot.username) {
|
|
808
|
+
await this.trackSelfReply(ref, scopeKey, tracker);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
786
812
|
const wakeAuthor = event.type === "comment_created" ? event.comment?.author : event.type === "issue_opened" ? event.issue?.author : undefined;
|
|
787
813
|
const wakeKind = event.type === "comment_created" ? event.comment?.authorKind ?? "human" : "human";
|
|
788
814
|
if (wakeAuthor) {
|
|
@@ -822,6 +848,33 @@ export class Engine {
|
|
|
822
848
|
}
|
|
823
849
|
}
|
|
824
850
|
|
|
851
|
+
// Reply-burst circuit breaker: a looping session can re-perceive a standing
|
|
852
|
+
// instruction every agent turn and invoke the reply tool indefinitely. Our
|
|
853
|
+
// own replies come back as comment_created events, so count them per issue
|
|
854
|
+
// and kill the running sessions when they exceed max within the window.
|
|
855
|
+
private async trackSelfReply(ref: TrackerRef, scopeKey: string, tracker: IssueTracker) {
|
|
856
|
+
const key = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
857
|
+
const max = this.replyBurstCfg?.max ?? Engine.MAX_REPLY_BURST;
|
|
858
|
+
const windowMs = this.replyBurstCfg?.windowMs ?? Engine.REPLY_BURST_WINDOW_MS;
|
|
859
|
+
const { tripped, kept } = replyBurstState(this.replyStamps.get(key) ?? [], Date.now(), max, windowMs);
|
|
860
|
+
this.replyStamps.set(key, kept);
|
|
861
|
+
if (!tripped) return;
|
|
862
|
+
this.replyStamps.delete(key);
|
|
863
|
+
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
864
|
+
if (!issue) return;
|
|
865
|
+
const sessions = await this.store.getSessionsForIssue(issue.id);
|
|
866
|
+
const killed: string[] = [];
|
|
867
|
+
for (const session of sessions) {
|
|
868
|
+
const k = this.sessionKey(session, issue);
|
|
869
|
+
if (!this.running.has(k)) continue;
|
|
870
|
+
const ok = await this.killSessionProcess(session, k);
|
|
871
|
+
if (ok) killed.push(session.name);
|
|
872
|
+
}
|
|
873
|
+
if (killed.length === 0) return; // burst authored elsewhere (another daemon) — nothing to kill here
|
|
874
|
+
log.warn(`engine: reply-burst breaker tripped for ${key} (${max} replies in ${Math.round(windowMs / 1000)}s) — killed ${killed.join(", ")}`);
|
|
875
|
+
await tracker.createComment(ref, `[system] ⚠️ Reply-burst circuit breaker: ${max} replies within ${Math.round(windowMs / 60000)} min — stopped ${killed.length > 1 ? `${killed.length} sessions` : `session **${killed[0]}**`}. Post a comment to wake it again.`).catch((err) => log.error(`engine: burst notice failed for ${key}:`, (err as Error).message));
|
|
876
|
+
}
|
|
877
|
+
|
|
825
878
|
private groupConfigFor(issue: Issue): GroupConfig | undefined {
|
|
826
879
|
return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`);
|
|
827
880
|
}
|
|
@@ -1025,6 +1078,7 @@ export class Engine {
|
|
|
1025
1078
|
scopeKey: string,
|
|
1026
1079
|
tracker: IssueTracker
|
|
1027
1080
|
) {
|
|
1081
|
+
this.replyStamps.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
1028
1082
|
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
1029
1083
|
if (!issue) return;
|
|
1030
1084
|
if (issue.state === "closed") return;
|
|
@@ -1074,6 +1128,7 @@ export class Engine {
|
|
|
1074
1128
|
scopeKey: string,
|
|
1075
1129
|
tracker: IssueTracker
|
|
1076
1130
|
) {
|
|
1131
|
+
this.replyStamps.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
1077
1132
|
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
1078
1133
|
if (!issue) return;
|
|
1079
1134
|
this.stopObserver(issue.id);
|