ework-daemon 0.4.14 → 0.4.15
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/opencode.ts +109 -29
- package/src/trackers/gitea-tracker.ts +9 -0
- package/src/trackers/types.ts +6 -0
package/package.json
CHANGED
package/src/opencode.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
2
3
|
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
|
|
3
4
|
import { join, resolve, isAbsolute } from "path";
|
|
4
5
|
import { homedir } from "os";
|
|
@@ -21,9 +22,7 @@ interface TrackerRegistry {
|
|
|
21
22
|
* the coordination layer.
|
|
22
23
|
*/
|
|
23
24
|
export interface TakeoverStrategy {
|
|
24
|
-
|
|
25
|
-
acquireWorkdir(session: OpSession, issue: Issue): Promise<string>;
|
|
26
|
-
/** Return an opencode session id to resume, or null for a fresh session. */
|
|
25
|
+
acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string): Promise<string>;
|
|
27
26
|
resumeOpenCodeSession(session: OpSession): Promise<string | null>;
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -101,7 +100,7 @@ export async function runHookScript(script: string | undefined, workdir: string,
|
|
|
101
100
|
export class RecloneStrategy implements TakeoverStrategy {
|
|
102
101
|
constructor(private cfg: Config) {}
|
|
103
102
|
|
|
104
|
-
async acquireWorkdir(session: OpSession, issue: Issue): Promise<string> {
|
|
103
|
+
async acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string): Promise<string> {
|
|
105
104
|
if (session.workdir) {
|
|
106
105
|
let dir = session.workdir;
|
|
107
106
|
if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
|
|
@@ -122,21 +121,20 @@ export class RecloneStrategy implements TakeoverStrategy {
|
|
|
122
121
|
try {
|
|
123
122
|
const entries = readdirSync(dir);
|
|
124
123
|
if (entries.length === 0) {
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// opencode binary path instead of the missing cwd. Fall back to
|
|
132
|
-
// `git init` so the workdir is at least a valid git repo the agent
|
|
133
|
-
// can work in.
|
|
124
|
+
const url = cloneUrl ?? `${this.cfg.gitea.url.replace(/\/$/, "")}/${owner}/${repo}.git`;
|
|
125
|
+
const credHelper = process.env.WORK_GIT_CREDENTIAL_HELPER;
|
|
126
|
+
const gitArgs = ["git"];
|
|
127
|
+
if (credHelper) gitArgs.push("-c", `credential.helper=${credHelper}`);
|
|
128
|
+
gitArgs.push("clone", url, dir);
|
|
129
|
+
const r = Bun.spawnSync({ cmd: gitArgs, stdout: "ignore", stderr: "pipe" });
|
|
134
130
|
if (r.exitCode !== 0) {
|
|
135
131
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
136
132
|
if (existsSync(dir) && readdirSync(dir).length === 0) {
|
|
137
133
|
Bun.spawnSync({ cmd: ["git", "init", dir], stdout: "ignore", stderr: "ignore" });
|
|
138
134
|
}
|
|
139
|
-
|
|
135
|
+
const stderrBuf = r.stderr as Uint8Array | undefined;
|
|
136
|
+
const stderrText = stderrBuf ? new TextDecoder().decode(stderrBuf).slice(0, 500) : "";
|
|
137
|
+
log.warn(`acquireWorkdir: git clone failed (exit ${r.exitCode}) for ${url}${stderrText ? `: ${stderrText}` : ""}; fell back to empty workdir`);
|
|
140
138
|
}
|
|
141
139
|
}
|
|
142
140
|
} catch {
|
|
@@ -227,6 +225,7 @@ export class Engine {
|
|
|
227
225
|
private progressCommentId = new Map<string, string>();
|
|
228
226
|
|
|
229
227
|
private nudgeRounds = new Map<string, number>();
|
|
228
|
+
private emptyResponseRounds = new Map<string, number>();
|
|
230
229
|
private processExitNudgeRounds = new Map<string, number>();
|
|
231
230
|
private stuckNudgeRounds = new Map<string, number>();
|
|
232
231
|
private currentPrompt = new Map<string, string>();
|
|
@@ -241,9 +240,12 @@ export class Engine {
|
|
|
241
240
|
private observerTimer?: ReturnType<typeof setInterval>;
|
|
242
241
|
|
|
243
242
|
private groupConfigs = new Map<string, GroupConfig>();
|
|
243
|
+
private cloneUrls = new Map<string, string>();
|
|
244
|
+
private senders = new Map<string, string>();
|
|
244
245
|
|
|
245
246
|
private static MAX_INLINE_SIZE = 4000;
|
|
246
247
|
private static MAX_NUDGE_ROUNDS = 1;
|
|
248
|
+
private static MAX_EMPTY_RESPONSE_ROUNDS = 1;
|
|
247
249
|
private static MAX_STUCK_NUDGE_ROUNDS = 1;
|
|
248
250
|
private static OBSERVER_INTERVAL_MS = 5 * 60 * 1000;
|
|
249
251
|
private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
|
|
@@ -334,20 +336,26 @@ export class Engine {
|
|
|
334
336
|
mkdirSync(dir, { recursive: true });
|
|
335
337
|
return dir;
|
|
336
338
|
}
|
|
337
|
-
|
|
339
|
+
const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
|
|
340
|
+
const cloneUrl = this.cloneUrls.get(issueMapKey);
|
|
341
|
+
return this.takeover.acquireWorkdir(session, issue, cloneUrl);
|
|
338
342
|
}
|
|
339
343
|
|
|
340
344
|
private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
|
|
341
345
|
const parts = issue.trackerScopeKey.split("/");
|
|
342
346
|
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "";
|
|
343
347
|
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "";
|
|
344
|
-
|
|
348
|
+
const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
|
|
349
|
+
const sender = this.senders.get(issueMapKey);
|
|
350
|
+
const env: Record<string, string> = {
|
|
345
351
|
EWORK_OWNER: String(owner),
|
|
346
352
|
EWORK_REPO: String(repo),
|
|
347
353
|
EWORK_ISSUE: String(issue.trackerIssueId),
|
|
348
354
|
EWORK_SESSION: session.name,
|
|
349
355
|
EWORK_WORKDIR: workdir,
|
|
350
356
|
};
|
|
357
|
+
if (sender) env.EWORK_SENDER = sender;
|
|
358
|
+
return env;
|
|
351
359
|
}
|
|
352
360
|
|
|
353
361
|
private workdirPathFor(session: OpSession, issue: Issue): string {
|
|
@@ -427,11 +435,23 @@ export class Engine {
|
|
|
427
435
|
return comments.filter(c => tracker.isBotUser(c.author) && !this.isSystemComment(c)).length;
|
|
428
436
|
}
|
|
429
437
|
|
|
430
|
-
private hasRecentBotReply(comments: TrackerComment[], tracker: IssueTracker): boolean {
|
|
438
|
+
private hasRecentBotReply(comments: TrackerComment[], tracker: IssueTracker, promptTime?: number): boolean {
|
|
439
|
+
// Causal check: if we know when this prompt was delivered, look for any
|
|
440
|
+
// bot reply CREATED AFTER that time. This is the correct signal — "did the
|
|
441
|
+
// AI reply to THIS prompt?" — and avoids false "done" when a previous
|
|
442
|
+
// round's reply is still within an absolute time window.
|
|
443
|
+
if (promptTime) {
|
|
444
|
+
return comments.some(c => {
|
|
445
|
+
if (!tracker.isBotUser(c.author) || this.isSystemComment(c)) return false;
|
|
446
|
+
if (!c.createdAt) return false; // no timestamp → can't confirm it's after prompt
|
|
447
|
+
return new Date(c.createdAt).getTime() > promptTime;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
// Fallback: absolute 5-minute window (for stuck-check that has no prompt time)
|
|
431
451
|
const now = Date.now();
|
|
432
452
|
return comments.some(c => {
|
|
433
453
|
if (!tracker.isBotUser(c.author) || this.isSystemComment(c)) return false;
|
|
434
|
-
if (!c.createdAt) return true;
|
|
454
|
+
if (!c.createdAt) return true;
|
|
435
455
|
const age = now - new Date(c.createdAt).getTime();
|
|
436
456
|
return age < Engine.RECENT_BOT_REPLY_THRESHOLD_MS;
|
|
437
457
|
});
|
|
@@ -441,6 +461,28 @@ export class Engine {
|
|
|
441
461
|
return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
|
|
442
462
|
}
|
|
443
463
|
|
|
464
|
+
private async checkSessionOutput(opencodeSessionId: string | undefined): Promise<{ hasOutput: boolean; tokenCount: number }> {
|
|
465
|
+
if (!opencodeSessionId) return { hasOutput: true, tokenCount: 0 };
|
|
466
|
+
let db: Database;
|
|
467
|
+
try {
|
|
468
|
+
db = new Database(this.cfg.opencode.dbPath, { readonly: true });
|
|
469
|
+
} catch {
|
|
470
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const row = db.prepare(
|
|
474
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(CAST(json_extract(data,'$.tokens.output') AS INT)), 0) AS tokens " +
|
|
475
|
+
"FROM message WHERE session_id = ? AND json_extract(data,'$.role') = 'assistant'"
|
|
476
|
+
).get(opencodeSessionId) as { n: number; tokens: number } | null;
|
|
477
|
+
if (!row) return { hasOutput: true, tokenCount: 0 };
|
|
478
|
+
return { hasOutput: row.n > 0 && row.tokens > 0, tokenCount: row.tokens };
|
|
479
|
+
} catch {
|
|
480
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
481
|
+
} finally {
|
|
482
|
+
db.close();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
444
486
|
// ─── Event Dispatch ───
|
|
445
487
|
|
|
446
488
|
async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
|
|
@@ -448,8 +490,15 @@ export class Engine {
|
|
|
448
490
|
const tracker = this.getTracker(ref.trackerType);
|
|
449
491
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
450
492
|
|
|
493
|
+
const issueMapKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
451
494
|
if (groupConfig) {
|
|
452
|
-
this.groupConfigs.set(
|
|
495
|
+
this.groupConfigs.set(issueMapKey, groupConfig);
|
|
496
|
+
}
|
|
497
|
+
if (event.cloneUrl) {
|
|
498
|
+
this.cloneUrls.set(issueMapKey, event.cloneUrl);
|
|
499
|
+
}
|
|
500
|
+
if (event.sender) {
|
|
501
|
+
this.senders.set(issueMapKey, event.sender);
|
|
453
502
|
}
|
|
454
503
|
|
|
455
504
|
switch (event.type) {
|
|
@@ -689,6 +738,8 @@ export class Engine {
|
|
|
689
738
|
}
|
|
690
739
|
}
|
|
691
740
|
if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey);
|
|
741
|
+
this.cloneUrls.delete(gcKey);
|
|
742
|
+
this.senders.delete(gcKey);
|
|
692
743
|
|
|
693
744
|
log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
|
|
694
745
|
}
|
|
@@ -1005,17 +1056,21 @@ export class Engine {
|
|
|
1005
1056
|
log.info(`engine: finishRun aborted (superseded) for ${k}`);
|
|
1006
1057
|
return;
|
|
1007
1058
|
}
|
|
1008
|
-
const hasRecent = this.hasRecentBotReply(commentsNow, tracker);
|
|
1059
|
+
const hasRecent = this.hasRecentBotReply(commentsNow, tracker, started ?? undefined);
|
|
1009
1060
|
|
|
1010
1061
|
if (hasRecent) {
|
|
1011
|
-
|
|
1062
|
+
const matched = commentsNow.find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c) && c.createdAt && new Date(c.createdAt).getTime() > (started ?? 0));
|
|
1063
|
+
log.info(`engine: [bot] reply found for ${k} after prompt (comment ${matched?.id ?? "?"} createdAt ${matched?.createdAt ?? "?"}), marking done`);
|
|
1012
1064
|
this.nudgeRounds.delete(k);
|
|
1065
|
+
this.emptyResponseRounds.delete(k);
|
|
1013
1066
|
await this.persistRuntimeState(session.id);
|
|
1014
1067
|
} else {
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1068
|
+
const sessionOutput = await this.checkSessionOutput(session.opencodeSessionId);
|
|
1069
|
+
const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
|
|
1070
|
+
|
|
1071
|
+
if (!sessionOutput.hasOutput && emptyRound < Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
|
|
1072
|
+
log.warn(`engine: empty model response for ${k} (0 tokens, round ${emptyRound + 1}/${Engine.MAX_EMPTY_RESPONSE_ROUNDS}), retrying`);
|
|
1073
|
+
this.emptyResponseRounds.set(k, emptyRound + 1);
|
|
1019
1074
|
this.currentPrompt.delete(k);
|
|
1020
1075
|
await this.persistRuntimeState(session.id);
|
|
1021
1076
|
|
|
@@ -1025,10 +1080,31 @@ export class Engine {
|
|
|
1025
1080
|
await this.dequeueOrIdle(k, session, issue, nudgeMsg);
|
|
1026
1081
|
return;
|
|
1027
1082
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1083
|
+
|
|
1084
|
+
if (!sessionOutput.hasOutput && emptyRound >= Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
|
|
1085
|
+
log.error(`engine: empty model response for ${k} after ${emptyRound} retries, reporting error`);
|
|
1086
|
+
this.emptyResponseRounds.delete(k);
|
|
1087
|
+
this.nudgeRounds.delete(k);
|
|
1088
|
+
await tracker.createComment(ref, `[system] ❌ **${session.name}** 模型返回空响应(0 token),已重试 ${emptyRound} 次。请检查模型配置或稍后重试。`).catch(() => {});
|
|
1089
|
+
} else {
|
|
1090
|
+
const nudgeRound = this.nudgeRounds.get(k) ?? 0;
|
|
1091
|
+
if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
|
|
1092
|
+
log.info(`engine: no [bot] reply for ${k} (promptTime=${started ?? "unknown"}), nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
|
|
1093
|
+
this.nudgeRounds.set(k, nudgeRound + 1);
|
|
1094
|
+
this.currentPrompt.delete(k);
|
|
1095
|
+
await this.persistRuntimeState(session.id);
|
|
1096
|
+
|
|
1097
|
+
const instructions = tracker.getTrackerInstructions(ref);
|
|
1098
|
+
const nudgePrompt = this.buildNudgePrompt(session, issue, instructions);
|
|
1099
|
+
const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt, undefined, undefined, this.currentModel.get(k));
|
|
1100
|
+
await this.dequeueOrIdle(k, session, issue, nudgeMsg);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
log.info(`engine: no [bot] reply for ${k} (promptTime=${started ?? "unknown"}), marking done (nudge exhausted or process failed)`);
|
|
1104
|
+
this.nudgeRounds.delete(k);
|
|
1105
|
+
const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
|
|
1106
|
+
await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
|
|
1107
|
+
}
|
|
1032
1108
|
}
|
|
1033
1109
|
|
|
1034
1110
|
// Remove eyes on source comment; react +1/-1 on the bot's last reply (fallback: source)
|
|
@@ -1619,5 +1695,9 @@ export class Engine {
|
|
|
1619
1695
|
this.stuckNudgeRounds.clear();
|
|
1620
1696
|
this.currentPrompt.clear();
|
|
1621
1697
|
this.generation.clear();
|
|
1698
|
+
this.groupConfigs.clear();
|
|
1699
|
+
this.cloneUrls.clear();
|
|
1700
|
+
this.senders.clear();
|
|
1701
|
+
this.emptyResponseRounds.clear();
|
|
1622
1702
|
}
|
|
1623
1703
|
}
|
|
@@ -119,6 +119,9 @@ export class GiteaTracker implements IssueTracker {
|
|
|
119
119
|
const repoName = repository.name as string;
|
|
120
120
|
if (!repoOwner || !repoName) return null;
|
|
121
121
|
|
|
122
|
+
const cloneUrl = typeof repository.clone_url === "string" ? repository.clone_url : undefined;
|
|
123
|
+
const sender = (payload.sender as Record<string, string>)?.login;
|
|
124
|
+
|
|
122
125
|
// ework-web extension (non-Gitea field, ignored by strict Gitea consumers).
|
|
123
126
|
// Empty/missing = no model override; engine omits --model.
|
|
124
127
|
const modelRaw = repository.ework_model;
|
|
@@ -143,6 +146,8 @@ export class GiteaTracker implements IssueTracker {
|
|
|
143
146
|
author: issueUser?.login ?? "",
|
|
144
147
|
},
|
|
145
148
|
model,
|
|
149
|
+
cloneUrl,
|
|
150
|
+
sender,
|
|
146
151
|
};
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -163,6 +168,8 @@ export class GiteaTracker implements IssueTracker {
|
|
|
163
168
|
author: commentUser?.login ?? "",
|
|
164
169
|
},
|
|
165
170
|
model,
|
|
171
|
+
cloneUrl,
|
|
172
|
+
sender,
|
|
166
173
|
};
|
|
167
174
|
}
|
|
168
175
|
|
|
@@ -177,6 +184,8 @@ export class GiteaTracker implements IssueTracker {
|
|
|
177
184
|
author: issueUser?.login ?? "",
|
|
178
185
|
},
|
|
179
186
|
model,
|
|
187
|
+
cloneUrl,
|
|
188
|
+
sender,
|
|
180
189
|
};
|
|
181
190
|
}
|
|
182
191
|
|
package/src/trackers/types.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface TrackerEvent {
|
|
|
33
33
|
// global default). Empty/undefined = no override; engine omits --model
|
|
34
34
|
// and lets opencode pick per its own opencode.json + env.
|
|
35
35
|
model?: string;
|
|
36
|
+
// Real clone URL from the upstream tracker (e.g. Gitea repository.clone_url).
|
|
37
|
+
// When present, RecloneStrategy uses this instead of the ework shim URL.
|
|
38
|
+
cloneUrl?: string;
|
|
39
|
+
// Webhook sender (who triggered the event). Forwarded to the daemon's
|
|
40
|
+
// task context so the AI knows who it's responding to.
|
|
41
|
+
sender?: string;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/** Tracker-agnostic comment */
|