ework-daemon 0.4.63 → 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.63",
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",
@@ -0,0 +1,102 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+
3
+ /**
4
+ * ework-web attachment links: `[name](/attachments/<uuid>)` in issue bodies
5
+ * and comments. The web requires auth on that route, but the daemon's Gitea
6
+ * PAT is accepted (same auth surface as cookies), so the daemon downloads
7
+ * attachments for the agent instead of teaching it to curl with tokens.
8
+ */
9
+ export const ATTACHMENT_LINK_RE = /\/attachments\/([0-9a-fA-F-]{36})/g;
10
+
11
+ export interface DownloadedAttachment {
12
+ uuid: string;
13
+ filename: string;
14
+ size: number;
15
+ /** Reason the referenced attachment was not downloaded, if any. */
16
+ skipped?: string;
17
+ }
18
+
19
+ export const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
20
+
21
+ function sanitizeFilename(raw: string, fallback: string): string {
22
+ const cleaned = raw
23
+ .replace(/[\u0000-\u001f\u007f]/g, "")
24
+ .replace(/[/\\]/g, "_")
25
+ .replace(/^\.+/, "")
26
+ .trim();
27
+ return cleaned || fallback;
28
+ }
29
+
30
+ /**
31
+ * Download every /attachments/<uuid> referenced in `content` into
32
+ * `<workdir>/attachments/` using the daemon's Gitea PAT. Best-effort:
33
+ * failures are reported per-attachment via `skipped`, never thrown.
34
+ */
35
+ export async function downloadIssueAttachments(
36
+ content: string,
37
+ baseUrl: string,
38
+ token: string,
39
+ workdir: string,
40
+ ): Promise<DownloadedAttachment[]> {
41
+ const uuids = new Set<string>();
42
+ for (const m of content.matchAll(ATTACHMENT_LINK_RE)) {
43
+ if (m[1]) uuids.add(m[1]);
44
+ }
45
+ if (uuids.size === 0) return [];
46
+ const base = baseUrl.replace(/\/+$/, "");
47
+ const out: DownloadedAttachment[] = [];
48
+ for (const uuid of uuids) {
49
+ try {
50
+ const res = await fetch(`${base}/attachments/${uuid}`, {
51
+ headers: { authorization: `token ${token}` },
52
+ signal: AbortSignal.timeout(120_000),
53
+ // Auth failures surface as 302 -> login; treat redirects as failures
54
+ // instead of following them to the login page.
55
+ redirect: "manual",
56
+ });
57
+ if (res.status >= 300 && res.status < 400) {
58
+ out.push({ uuid, filename: "", size: 0, skipped: `HTTP ${res.status} (auth)` });
59
+ continue;
60
+ }
61
+ if (!res.ok) {
62
+ out.push({ uuid, filename: "", size: 0, skipped: `HTTP ${res.status}` });
63
+ continue;
64
+ }
65
+ const lenHeader = Number(res.headers.get("content-length") ?? "0");
66
+ if (lenHeader > MAX_ATTACHMENT_BYTES) {
67
+ out.push({ uuid, filename: "", size: lenHeader, skipped: "too large" });
68
+ continue;
69
+ }
70
+ const cd = res.headers.get("content-disposition") ?? "";
71
+ const nameMatch = cd.match(/filename="([^"]*)"/);
72
+ const filename = sanitizeFilename(nameMatch?.[1] ?? "", `${uuid}.bin`);
73
+ const buf = Buffer.from(await res.arrayBuffer());
74
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
75
+ out.push({ uuid, filename, size: buf.length, skipped: "too large" });
76
+ continue;
77
+ }
78
+ const dir = `${workdir}/attachments`;
79
+ await mkdir(dir, { recursive: true });
80
+ await writeFile(`${dir}/${filename}`, buf);
81
+ out.push({ uuid, filename, size: buf.length });
82
+ } catch (e) {
83
+ out.push({
84
+ uuid,
85
+ filename: "",
86
+ size: 0,
87
+ skipped: e instanceof Error ? e.message : "download error",
88
+ });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export function attachmentNote(atts: DownloadedAttachment[]): string {
95
+ if (atts.length === 0) return "";
96
+ const lines = atts.map((a) =>
97
+ a.skipped
98
+ ? `- ${a.filename || a.uuid}: 未能下载(${a.skipped})`
99
+ : `- attachments/${a.filename}(${(a.size / 1024).toFixed(1)} KB)`,
100
+ );
101
+ return `\n\n[system] 本条消息引用的附件已由系统代为下载到工作目录的 attachments/ 目录:\n${lines.join("\n")}\n请直接用文件工具读取分析(日志类文件建议分段/grep 查看)。`;
102
+ }
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
@@ -10,6 +10,7 @@ import { formatKey, parseKey } from "./trackers/types";
10
10
  import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
11
11
  import { OpencodeBackend } from "./runtime/opencode-backend";
12
12
  import { PiBackend } from "./runtime/pi-backend";
13
+ import { downloadIssueAttachments, attachmentNote } from "./attachments";
13
14
 
14
15
  // ─── Types ───
15
16
 
@@ -300,6 +301,7 @@ export interface EngineOptions {
300
301
  takeover?: TakeoverStrategy;
301
302
  backend?: RuntimeBackend;
302
303
  gateChecker?: (issue: Issue) => Promise<{ allowed: boolean; reason: string }>;
304
+ replyBurst?: { max: number; windowMs: number };
303
305
  }
304
306
 
305
307
  function createDefaultBackend(cfg: Config): RuntimeBackend {
@@ -330,6 +332,19 @@ export function wakePolicySkips(
330
332
  return null;
331
333
  }
332
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
+
333
348
  // Per-issue npm prefix: `npm install -g <pkg>` inside a session lands in the issue's
334
349
  // workdir instead of the system global, so concurrent agents debugging different
335
350
  // issues cannot clobber each other's global installs (nor poison the shared daemon env).
@@ -397,6 +412,8 @@ export class Engine {
397
412
  private altBackend?: RuntimeBackend;
398
413
  private senders = new Map<string, string>();
399
414
  private envInitialized = new Set<string>();
415
+ private replyBurstCfg?: { max: number; windowMs: number };
416
+ private replyStamps = new Map<string, number[]>();
400
417
 
401
418
  private static MAX_INLINE_SIZE = 4000;
402
419
  private static MAX_NUDGE_ROUNDS = 1;
@@ -405,6 +422,8 @@ export class Engine {
405
422
  private static MAX_RUNTIME_MS = 3 * 60 * 60 * 1000;
406
423
  private static OBSERVER_INTERVAL_MS = 5 * 60 * 1000;
407
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;
408
427
  private static MAX_PROCESS_EXIT_NUDGE_ROUNDS = 1;
409
428
 
410
429
  constructor(cfg: Config, store: Store, trackers: TrackerRegistry, opts: EngineOptions) {
@@ -415,6 +434,7 @@ export class Engine {
415
434
  this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
416
435
  this.backend = opts.backend ?? createDefaultBackend(cfg);
417
436
  this.gateChecker = opts.gateChecker ?? ((issue: Issue) => this.webGateAllows(issue));
437
+ this.replyBurstCfg = opts.replyBurst ?? cfg.replyBurst;
418
438
  this.maxConcurrent = cfg.work.maxConcurrent;
419
439
  this.maxConcurrentExplicit = cfg.work.maxConcurrentExplicit;
420
440
  this.startGlobalObserver();
@@ -782,6 +802,13 @@ export class Engine {
782
802
  return;
783
803
  }
784
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
+
785
812
  const wakeAuthor = event.type === "comment_created" ? event.comment?.author : event.type === "issue_opened" ? event.issue?.author : undefined;
786
813
  const wakeKind = event.type === "comment_created" ? event.comment?.authorKind ?? "human" : "human";
787
814
  if (wakeAuthor) {
@@ -821,6 +848,33 @@ export class Engine {
821
848
  }
822
849
  }
823
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
+
824
878
  private groupConfigFor(issue: Issue): GroupConfig | undefined {
825
879
  return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`);
826
880
  }
@@ -1024,6 +1078,7 @@ export class Engine {
1024
1078
  scopeKey: string,
1025
1079
  tracker: IssueTracker
1026
1080
  ) {
1081
+ this.replyStamps.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`);
1027
1082
  const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
1028
1083
  if (!issue) return;
1029
1084
  if (issue.state === "closed") return;
@@ -1073,6 +1128,7 @@ export class Engine {
1073
1128
  scopeKey: string,
1074
1129
  tracker: IssueTracker
1075
1130
  ) {
1131
+ this.replyStamps.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`);
1076
1132
  const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
1077
1133
  if (!issue) return;
1078
1134
  this.stopObserver(issue.id);
@@ -1278,13 +1334,33 @@ export class Engine {
1278
1334
 
1279
1335
  const childEnv = spawnEnvFor(process.env, this.hookEnvFor(issue, session, workdir), workdir);
1280
1336
 
1337
+ let spawnPrompt = msg.content;
1338
+ try {
1339
+ const atts = await downloadIssueAttachments(
1340
+ msg.content,
1341
+ this.cfg.gitea.url,
1342
+ this.cfg.gitea.token,
1343
+ workdir,
1344
+ );
1345
+ if (atts.length > 0) {
1346
+ log.info(
1347
+ `engine: attachments for ${k}: ${atts
1348
+ .map((a) => `${a.filename || a.uuid}${a.skipped ? ` (skip: ${a.skipped})` : ""}`)
1349
+ .join(", ")}`,
1350
+ );
1351
+ spawnPrompt += attachmentNote(atts);
1352
+ }
1353
+ } catch {
1354
+ // Best-effort: the agent still has the raw message without files.
1355
+ }
1356
+
1281
1357
  let exitCode: number | null = null;
1282
1358
 
1283
1359
  try {
1284
1360
  const handle = await backend.spawn(
1285
1361
  {
1286
1362
  workdir,
1287
- prompt: msg.content,
1363
+ prompt: spawnPrompt,
1288
1364
  model: model || undefined,
1289
1365
  resumeSessionId: resumeSessionId || undefined,
1290
1366
  env: childEnv,