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 +1 -1
- package/src/attachments.ts +102 -0
- package/src/config.ts +12 -0
- package/src/opencode.ts +77 -1
package/package.json
CHANGED
|
@@ -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:
|
|
1363
|
+
prompt: spawnPrompt,
|
|
1288
1364
|
model: model || undefined,
|
|
1289
1365
|
resumeSessionId: resumeSessionId || undefined,
|
|
1290
1366
|
env: childEnv,
|