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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.64",
3
+ "version": "0.4.66",
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();
@@ -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);