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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.13",
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;
@@ -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}&daemon=${ep})`;
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
- const ep = encodeURIComponent(this.daemonEndpoint());
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
- 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);
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
- return {
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; // no timestamp — assume recent to avoid false nudges
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(`${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);
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
- 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`);
1018
1064
  this.nudgeRounds.delete(k);
1065
+ this.emptyResponseRounds.delete(k);
1019
1066
  await this.persistRuntimeState(session.id);
1020
1067
  } else {
1021
- const nudgeRound = this.nudgeRounds.get(k) ?? 0;
1022
- if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
1023
- log.info(`engine: no recent [bot] reply for ${k}, nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
1024
- 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);
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
- log.info(`engine: no recent [bot] reply for ${k}, marking done (nudge exhausted or process failed)`);
1035
- this.nudgeRounds.delete(k);
1036
- const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
1037
- 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
+ }
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
 
@@ -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 */