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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.64",
3
+ "version": "0.4.65",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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);