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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
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/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
- /** Resolve (and ensure exists) the workdir for this session+issue. */
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 base = this.cfg.gitea.url.replace(/\/$/, "");
126
- const url = `${base}/${owner}/${repo}.git`;
127
- const r = Bun.spawnSync({ cmd: ["git", "clone", url, dir], stdout: "ignore", stderr: "ignore" });
128
- // git clone deletes the target dir on failure (e.g. gitea shim
129
- // without smart-http /info/refs 404). Re-create the workdir so
130
- // Bun.spawn later doesn't throw a misleading ENOENT pointing at the
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
- log.warn(`acquireWorkdir: git clone failed (exit ${r.exitCode}) for ${url}; fell back to empty workdir`);
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
- return this.takeover.acquireWorkdir(session, issue);
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
- return {
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; // no timestamp — assume recent to avoid false nudges
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(`${ref.trackerType}:${scopeKey}#${ref.issueId}`, groupConfig);
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
- log.info(`engine: recent [bot] reply found for ${k}, marking done`);
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 nudgeRound = this.nudgeRounds.get(k) ?? 0;
1016
- if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
1017
- log.info(`engine: no recent [bot] reply for ${k}, nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
1018
- this.nudgeRounds.set(k, nudgeRound + 1);
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
- log.info(`engine: no recent [bot] reply for ${k}, marking done (nudge exhausted or process failed)`);
1029
- this.nudgeRounds.delete(k);
1030
- const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
1031
- await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
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
 
@@ -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 */