ework-daemon 0.4.64 → 0.4.66
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 +70 -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();
|
|
@@ -768,6 +787,21 @@ export class Engine {
|
|
|
768
787
|
const tracker = this.getTracker(ref.trackerType);
|
|
769
788
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
770
789
|
|
|
790
|
+
// State bookkeeping that must survive every wake gate: `issue_closed` is
|
|
791
|
+
// never gated, so if a later `reopened` (mapped to issue_opened) is
|
|
792
|
+
// dropped by paused/halted/dispatch_off/wake gates, the row stays
|
|
793
|
+
// "closed" forever and swallows ALL future comments ("is closed in DB").
|
|
794
|
+
// Heal the stale state unconditionally; dispatch remains gated below.
|
|
795
|
+
if (event.type === "issue_opened") {
|
|
796
|
+
const existing = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
797
|
+
if (existing?.state === "closed") {
|
|
798
|
+
await this.store.updateIssueState(existing.id, "active");
|
|
799
|
+
log.info(
|
|
800
|
+
`engine: reopened — cleared stale closed state for ${ref.trackerType}:${scopeKey}#${ref.issueId} (bookkeeping only; dispatch still gated)`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
771
805
|
if (this.paused && (event.type === "issue_opened" || event.type === "comment_created")) {
|
|
772
806
|
log.info(`engine: paused — skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
773
807
|
return;
|
|
@@ -783,6 +817,13 @@ export class Engine {
|
|
|
783
817
|
return;
|
|
784
818
|
}
|
|
785
819
|
|
|
820
|
+
// Self-authored comment (our own reply landing back): never wake on it,
|
|
821
|
+
// but feed the reply-burst circuit breaker first.
|
|
822
|
+
if (event.type === "comment_created" && event.comment?.author === this.cfg.bot.username) {
|
|
823
|
+
await this.trackSelfReply(ref, scopeKey, tracker);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
786
827
|
const wakeAuthor = event.type === "comment_created" ? event.comment?.author : event.type === "issue_opened" ? event.issue?.author : undefined;
|
|
787
828
|
const wakeKind = event.type === "comment_created" ? event.comment?.authorKind ?? "human" : "human";
|
|
788
829
|
if (wakeAuthor) {
|
|
@@ -822,6 +863,33 @@ export class Engine {
|
|
|
822
863
|
}
|
|
823
864
|
}
|
|
824
865
|
|
|
866
|
+
// Reply-burst circuit breaker: a looping session can re-perceive a standing
|
|
867
|
+
// instruction every agent turn and invoke the reply tool indefinitely. Our
|
|
868
|
+
// own replies come back as comment_created events, so count them per issue
|
|
869
|
+
// and kill the running sessions when they exceed max within the window.
|
|
870
|
+
private async trackSelfReply(ref: TrackerRef, scopeKey: string, tracker: IssueTracker) {
|
|
871
|
+
const key = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
872
|
+
const max = this.replyBurstCfg?.max ?? Engine.MAX_REPLY_BURST;
|
|
873
|
+
const windowMs = this.replyBurstCfg?.windowMs ?? Engine.REPLY_BURST_WINDOW_MS;
|
|
874
|
+
const { tripped, kept } = replyBurstState(this.replyStamps.get(key) ?? [], Date.now(), max, windowMs);
|
|
875
|
+
this.replyStamps.set(key, kept);
|
|
876
|
+
if (!tripped) return;
|
|
877
|
+
this.replyStamps.delete(key);
|
|
878
|
+
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
879
|
+
if (!issue) return;
|
|
880
|
+
const sessions = await this.store.getSessionsForIssue(issue.id);
|
|
881
|
+
const killed: string[] = [];
|
|
882
|
+
for (const session of sessions) {
|
|
883
|
+
const k = this.sessionKey(session, issue);
|
|
884
|
+
if (!this.running.has(k)) continue;
|
|
885
|
+
const ok = await this.killSessionProcess(session, k);
|
|
886
|
+
if (ok) killed.push(session.name);
|
|
887
|
+
}
|
|
888
|
+
if (killed.length === 0) return; // burst authored elsewhere (another daemon) — nothing to kill here
|
|
889
|
+
log.warn(`engine: reply-burst breaker tripped for ${key} (${max} replies in ${Math.round(windowMs / 1000)}s) — killed ${killed.join(", ")}`);
|
|
890
|
+
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));
|
|
891
|
+
}
|
|
892
|
+
|
|
825
893
|
private groupConfigFor(issue: Issue): GroupConfig | undefined {
|
|
826
894
|
return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`);
|
|
827
895
|
}
|
|
@@ -1025,6 +1093,7 @@ export class Engine {
|
|
|
1025
1093
|
scopeKey: string,
|
|
1026
1094
|
tracker: IssueTracker
|
|
1027
1095
|
) {
|
|
1096
|
+
this.replyStamps.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
1028
1097
|
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
1029
1098
|
if (!issue) return;
|
|
1030
1099
|
if (issue.state === "closed") return;
|
|
@@ -1074,6 +1143,7 @@ export class Engine {
|
|
|
1074
1143
|
scopeKey: string,
|
|
1075
1144
|
tracker: IssueTracker
|
|
1076
1145
|
) {
|
|
1146
|
+
this.replyStamps.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
1077
1147
|
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
1078
1148
|
if (!issue) return;
|
|
1079
1149
|
this.stopObserver(issue.id);
|