ework-daemon 0.4.13 → 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 +111 -37
- 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;
|
|
@@ -259,20 +261,14 @@ export class Engine {
|
|
|
259
261
|
void this.recover();
|
|
260
262
|
}
|
|
261
263
|
|
|
262
|
-
private daemonEndpoint(): string {
|
|
263
|
-
return this.cfg.daemon.endpoint || `${this.cfg.daemon.host}:${this.cfg.daemon.port}`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
264
|
private workdirLink(workdir: string): string {
|
|
267
|
-
const ep = encodeURIComponent(this.daemonEndpoint());
|
|
268
265
|
const p = encodeURIComponent(workdir);
|
|
269
|
-
return `[${workdir}](/file?path=${p}&
|
|
266
|
+
return `[${workdir}](/file?path=${p}&daemon_id=${this.daemonId})`;
|
|
270
267
|
}
|
|
271
268
|
|
|
272
269
|
private sessionRef(session: { id: string; opencodeSessionId?: string | null }): string {
|
|
273
270
|
const ses = session.opencodeSessionId || session.id;
|
|
274
|
-
|
|
275
|
-
return `[\`${ses}\`](/sessions/${encodeURIComponent(ses)}?daemon=${ep})`;
|
|
271
|
+
return `[\`${ses}\`](/sessions/${encodeURIComponent(ses)}?daemon_id=${this.daemonId})`;
|
|
276
272
|
}
|
|
277
273
|
|
|
278
274
|
/** Start the lease heartbeat. Must be called once after registerDaemon. */
|
|
@@ -340,20 +336,26 @@ export class Engine {
|
|
|
340
336
|
mkdirSync(dir, { recursive: true });
|
|
341
337
|
return dir;
|
|
342
338
|
}
|
|
343
|
-
|
|
339
|
+
const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
|
|
340
|
+
const cloneUrl = this.cloneUrls.get(issueMapKey);
|
|
341
|
+
return this.takeover.acquireWorkdir(session, issue, cloneUrl);
|
|
344
342
|
}
|
|
345
343
|
|
|
346
344
|
private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
|
|
347
345
|
const parts = issue.trackerScopeKey.split("/");
|
|
348
346
|
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "";
|
|
349
347
|
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "";
|
|
350
|
-
|
|
348
|
+
const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
|
|
349
|
+
const sender = this.senders.get(issueMapKey);
|
|
350
|
+
const env: Record<string, string> = {
|
|
351
351
|
EWORK_OWNER: String(owner),
|
|
352
352
|
EWORK_REPO: String(repo),
|
|
353
353
|
EWORK_ISSUE: String(issue.trackerIssueId),
|
|
354
354
|
EWORK_SESSION: session.name,
|
|
355
355
|
EWORK_WORKDIR: workdir,
|
|
356
356
|
};
|
|
357
|
+
if (sender) env.EWORK_SENDER = sender;
|
|
358
|
+
return env;
|
|
357
359
|
}
|
|
358
360
|
|
|
359
361
|
private workdirPathFor(session: OpSession, issue: Issue): string {
|
|
@@ -433,11 +435,23 @@ export class Engine {
|
|
|
433
435
|
return comments.filter(c => tracker.isBotUser(c.author) && !this.isSystemComment(c)).length;
|
|
434
436
|
}
|
|
435
437
|
|
|
436
|
-
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)
|
|
437
451
|
const now = Date.now();
|
|
438
452
|
return comments.some(c => {
|
|
439
453
|
if (!tracker.isBotUser(c.author) || this.isSystemComment(c)) return false;
|
|
440
|
-
if (!c.createdAt) return true;
|
|
454
|
+
if (!c.createdAt) return true;
|
|
441
455
|
const age = now - new Date(c.createdAt).getTime();
|
|
442
456
|
return age < Engine.RECENT_BOT_REPLY_THRESHOLD_MS;
|
|
443
457
|
});
|
|
@@ -447,6 +461,28 @@ export class Engine {
|
|
|
447
461
|
return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
|
|
448
462
|
}
|
|
449
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
|
+
|
|
450
486
|
// ─── Event Dispatch ───
|
|
451
487
|
|
|
452
488
|
async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
|
|
@@ -454,8 +490,15 @@ export class Engine {
|
|
|
454
490
|
const tracker = this.getTracker(ref.trackerType);
|
|
455
491
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
456
492
|
|
|
493
|
+
const issueMapKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
457
494
|
if (groupConfig) {
|
|
458
|
-
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);
|
|
459
502
|
}
|
|
460
503
|
|
|
461
504
|
switch (event.type) {
|
|
@@ -695,6 +738,8 @@ export class Engine {
|
|
|
695
738
|
}
|
|
696
739
|
}
|
|
697
740
|
if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey);
|
|
741
|
+
this.cloneUrls.delete(gcKey);
|
|
742
|
+
this.senders.delete(gcKey);
|
|
698
743
|
|
|
699
744
|
log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
|
|
700
745
|
}
|
|
@@ -1011,17 +1056,21 @@ export class Engine {
|
|
|
1011
1056
|
log.info(`engine: finishRun aborted (superseded) for ${k}`);
|
|
1012
1057
|
return;
|
|
1013
1058
|
}
|
|
1014
|
-
const hasRecent = this.hasRecentBotReply(commentsNow, tracker);
|
|
1059
|
+
const hasRecent = this.hasRecentBotReply(commentsNow, tracker, started ?? undefined);
|
|
1015
1060
|
|
|
1016
1061
|
if (hasRecent) {
|
|
1017
|
-
|
|
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`);
|
|
1018
1064
|
this.nudgeRounds.delete(k);
|
|
1065
|
+
this.emptyResponseRounds.delete(k);
|
|
1019
1066
|
await this.persistRuntimeState(session.id);
|
|
1020
1067
|
} else {
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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);
|
|
1025
1074
|
this.currentPrompt.delete(k);
|
|
1026
1075
|
await this.persistRuntimeState(session.id);
|
|
1027
1076
|
|
|
@@ -1031,10 +1080,31 @@ export class Engine {
|
|
|
1031
1080
|
await this.dequeueOrIdle(k, session, issue, nudgeMsg);
|
|
1032
1081
|
return;
|
|
1033
1082
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
+
}
|
|
1038
1108
|
}
|
|
1039
1109
|
|
|
1040
1110
|
// Remove eyes on source comment; react +1/-1 on the bot's last reply (fallback: source)
|
|
@@ -1625,5 +1695,9 @@ export class Engine {
|
|
|
1625
1695
|
this.stuckNudgeRounds.clear();
|
|
1626
1696
|
this.currentPrompt.clear();
|
|
1627
1697
|
this.generation.clear();
|
|
1698
|
+
this.groupConfigs.clear();
|
|
1699
|
+
this.cloneUrls.clear();
|
|
1700
|
+
this.senders.clear();
|
|
1701
|
+
this.emptyResponseRounds.clear();
|
|
1628
1702
|
}
|
|
1629
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 */
|