ework-daemon 0.4.14 → 0.4.16

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.16",
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, env?: Record<string, string>): Promise<string>;
27
26
  resumeOpenCodeSession(session: OpSession): Promise<string | null>;
28
27
  }
29
28
 
@@ -92,6 +91,68 @@ export async function runHookScript(script: string | undefined, workdir: string,
92
91
  }
93
92
  }
94
93
 
94
+ const SYSTEM_PREFIX = "[system]";
95
+ const RECENT_BOT_REPLY_THRESHOLD_MS = 5 * 60_000; // 5 minutes
96
+
97
+ /**
98
+ * Determine whether any non-system bot reply exists that is causally after
99
+ * `promptTime` (when provided) or within a 5-minute absolute window (fallback).
100
+ * Exported for unit testing — the causal vs absolute distinction is the P0
101
+ * correctness fix for preempt/nudge false-done detection.
102
+ */
103
+ export function hasRecentBotReply(
104
+ comments: TrackerComment[],
105
+ isBotUser: (author: string) => boolean,
106
+ promptTime?: number,
107
+ ): boolean {
108
+ if (promptTime) {
109
+ return comments.some(c => {
110
+ if (!isBotUser(c.author) || c.body.startsWith(SYSTEM_PREFIX)) return false;
111
+ if (!c.createdAt) return false;
112
+ return new Date(c.createdAt).getTime() > promptTime;
113
+ });
114
+ }
115
+ const now = Date.now();
116
+ return comments.some(c => {
117
+ if (!isBotUser(c.author) || c.body.startsWith(SYSTEM_PREFIX)) return false;
118
+ if (!c.createdAt) return true;
119
+ const age = now - new Date(c.createdAt).getTime();
120
+ return age < RECENT_BOT_REPLY_THRESHOLD_MS;
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Query the opencode SQLite DB for a session's assistant-message output tokens.
126
+ * Returns `{hasOutput: true}` (safe default) when the DB can't be opened or
127
+ * the session is undefined — this means the retry path is only triggered when
128
+ * we have POSITIVE evidence of 0-token output.
129
+ * Exported for unit testing.
130
+ */
131
+ export async function checkSessionOutput(
132
+ dbPath: string,
133
+ opencodeSessionId: string | undefined,
134
+ ): Promise<{ hasOutput: boolean; tokenCount: number }> {
135
+ if (!opencodeSessionId) return { hasOutput: true, tokenCount: 0 };
136
+ let db: Database;
137
+ try {
138
+ db = new Database(dbPath, { readonly: true });
139
+ } catch {
140
+ return { hasOutput: true, tokenCount: 0 };
141
+ }
142
+ try {
143
+ const row = db.prepare(
144
+ "SELECT COUNT(*) AS n, COALESCE(SUM(CAST(json_extract(data,'$.tokens.output') AS INT)), 0) AS tokens " +
145
+ "FROM message WHERE session_id = ? AND json_extract(data,'$.role') = 'assistant'"
146
+ ).get(opencodeSessionId) as { n: number; tokens: number } | null;
147
+ if (!row) return { hasOutput: true, tokenCount: 0 };
148
+ return { hasOutput: row.n > 0 && row.tokens > 0, tokenCount: row.tokens };
149
+ } catch {
150
+ return { hasOutput: true, tokenCount: 0 };
151
+ } finally {
152
+ db.close();
153
+ }
154
+ }
155
+
95
156
  /**
96
157
  * Default TakeoverStrategy: deterministic per-issue workdir under
97
158
  * `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
@@ -101,7 +162,7 @@ export async function runHookScript(script: string | undefined, workdir: string,
101
162
  export class RecloneStrategy implements TakeoverStrategy {
102
163
  constructor(private cfg: Config) {}
103
164
 
104
- async acquireWorkdir(session: OpSession, issue: Issue): Promise<string> {
165
+ async acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string, env?: Record<string, string>): Promise<string> {
105
166
  if (session.workdir) {
106
167
  let dir = session.workdir;
107
168
  if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
@@ -122,21 +183,20 @@ export class RecloneStrategy implements TakeoverStrategy {
122
183
  try {
123
184
  const entries = readdirSync(dir);
124
185
  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.
186
+ const url = cloneUrl ?? `${this.cfg.gitea.url.replace(/\/$/, "")}/${owner}/${repo}.git`;
187
+ const credHelper = process.env.WORK_GIT_CREDENTIAL_HELPER;
188
+ const gitArgs = ["git"];
189
+ if (credHelper) gitArgs.push("-c", `credential.helper=${credHelper}`);
190
+ gitArgs.push("clone", url, dir);
191
+ const r = Bun.spawnSync({ cmd: gitArgs, stdout: "ignore", stderr: "pipe", env: { ...process.env, ...env } });
134
192
  if (r.exitCode !== 0) {
135
193
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
136
194
  if (existsSync(dir) && readdirSync(dir).length === 0) {
137
195
  Bun.spawnSync({ cmd: ["git", "init", dir], stdout: "ignore", stderr: "ignore" });
138
196
  }
139
- log.warn(`acquireWorkdir: git clone failed (exit ${r.exitCode}) for ${url}; fell back to empty workdir`);
197
+ const stderrBuf = r.stderr as Uint8Array | undefined;
198
+ const stderrText = stderrBuf ? new TextDecoder().decode(stderrBuf).slice(0, 500) : "";
199
+ log.warn(`acquireWorkdir: git clone failed (exit ${r.exitCode}) for ${url}${stderrText ? `: ${stderrText}` : ""}; fell back to empty workdir`);
140
200
  }
141
201
  }
142
202
  } catch {
@@ -227,6 +287,7 @@ export class Engine {
227
287
  private progressCommentId = new Map<string, string>();
228
288
 
229
289
  private nudgeRounds = new Map<string, number>();
290
+ private emptyResponseRounds = new Map<string, number>();
230
291
  private processExitNudgeRounds = new Map<string, number>();
231
292
  private stuckNudgeRounds = new Map<string, number>();
232
293
  private currentPrompt = new Map<string, string>();
@@ -241,9 +302,12 @@ export class Engine {
241
302
  private observerTimer?: ReturnType<typeof setInterval>;
242
303
 
243
304
  private groupConfigs = new Map<string, GroupConfig>();
305
+ private cloneUrls = new Map<string, string>();
306
+ private senders = new Map<string, string>();
244
307
 
245
308
  private static MAX_INLINE_SIZE = 4000;
246
309
  private static MAX_NUDGE_ROUNDS = 1;
310
+ private static MAX_EMPTY_RESPONSE_ROUNDS = 1;
247
311
  private static MAX_STUCK_NUDGE_ROUNDS = 1;
248
312
  private static OBSERVER_INTERVAL_MS = 5 * 60 * 1000;
249
313
  private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
@@ -334,20 +398,35 @@ export class Engine {
334
398
  mkdirSync(dir, { recursive: true });
335
399
  return dir;
336
400
  }
337
- return this.takeover.acquireWorkdir(session, issue);
401
+ const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
402
+ const cloneUrl = this.cloneUrls.get(issueMapKey);
403
+ const owner = String(issue.trackerScope["owner"] ?? issue.trackerScopeKey.split("/")[0] ?? "");
404
+ const repo = String(issue.trackerScope["repo"] ?? issue.trackerScopeKey.split("/").slice(-1)[0] ?? "");
405
+ const sender = this.senders.get(issueMapKey);
406
+ const env: Record<string, string> = {
407
+ EWORK_OWNER: owner,
408
+ EWORK_REPO: repo,
409
+ EWORK_ISSUE: String(issue.trackerIssueId),
410
+ };
411
+ if (sender) env.EWORK_SENDER = sender;
412
+ return this.takeover.acquireWorkdir(session, issue, cloneUrl, env);
338
413
  }
339
414
 
340
415
  private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
341
416
  const parts = issue.trackerScopeKey.split("/");
342
417
  const owner = (issue.trackerScope["owner"] as string) || parts[0] || "";
343
418
  const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "";
344
- return {
419
+ const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
420
+ const sender = this.senders.get(issueMapKey);
421
+ const env: Record<string, string> = {
345
422
  EWORK_OWNER: String(owner),
346
423
  EWORK_REPO: String(repo),
347
424
  EWORK_ISSUE: String(issue.trackerIssueId),
348
425
  EWORK_SESSION: session.name,
349
426
  EWORK_WORKDIR: workdir,
350
427
  };
428
+ if (sender) env.EWORK_SENDER = sender;
429
+ return env;
351
430
  }
352
431
 
353
432
  private workdirPathFor(session: OpSession, issue: Issue): string {
@@ -415,32 +494,26 @@ export class Engine {
415
494
  return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
416
495
  }
417
496
 
418
- /** System comments are posted by the daemon itself (acks, progress, reports). They are NOT AI replies. */
419
- private static SYSTEM_PREFIX = "[system]";
420
- private static RECENT_BOT_REPLY_THRESHOLD_MS = 5 * 60_000; // 5 minutes
421
-
422
497
  private isSystemComment(comment: TrackerComment): boolean {
423
- return comment.body.startsWith(Engine.SYSTEM_PREFIX);
498
+ return comment.body.startsWith(SYSTEM_PREFIX);
424
499
  }
425
500
 
426
501
  private countAIReplies(comments: TrackerComment[], tracker: IssueTracker): number {
427
502
  return comments.filter(c => tracker.isBotUser(c.author) && !this.isSystemComment(c)).length;
428
503
  }
429
504
 
430
- private hasRecentBotReply(comments: TrackerComment[], tracker: IssueTracker): boolean {
431
- const now = Date.now();
432
- return comments.some(c => {
433
- if (!tracker.isBotUser(c.author) || this.isSystemComment(c)) return false;
434
- if (!c.createdAt) return true; // no timestamp — assume recent to avoid false nudges
435
- const age = now - new Date(c.createdAt).getTime();
436
- return age < Engine.RECENT_BOT_REPLY_THRESHOLD_MS;
437
- });
505
+ private hasRecentBotReply(comments: TrackerComment[], tracker: IssueTracker, promptTime?: number): boolean {
506
+ return hasRecentBotReply(comments, (a) => tracker.isBotUser(a), promptTime);
438
507
  }
439
508
 
440
509
  private lastBotReply(comments: TrackerComment[], tracker: IssueTracker): TrackerComment | undefined {
441
510
  return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
442
511
  }
443
512
 
513
+ private async checkSessionOutput(opencodeSessionId: string | undefined): Promise<{ hasOutput: boolean; tokenCount: number }> {
514
+ return checkSessionOutput(this.cfg.opencode.dbPath, opencodeSessionId);
515
+ }
516
+
444
517
  // ─── Event Dispatch ───
445
518
 
446
519
  async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
@@ -448,8 +521,15 @@ export class Engine {
448
521
  const tracker = this.getTracker(ref.trackerType);
449
522
  const scopeKey = tracker.formatScopeKey(ref.scope);
450
523
 
524
+ const issueMapKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
451
525
  if (groupConfig) {
452
- this.groupConfigs.set(`${ref.trackerType}:${scopeKey}#${ref.issueId}`, groupConfig);
526
+ this.groupConfigs.set(issueMapKey, groupConfig);
527
+ }
528
+ if (event.cloneUrl) {
529
+ this.cloneUrls.set(issueMapKey, event.cloneUrl);
530
+ }
531
+ if (event.sender) {
532
+ this.senders.set(issueMapKey, event.sender);
453
533
  }
454
534
 
455
535
  switch (event.type) {
@@ -689,6 +769,8 @@ export class Engine {
689
769
  }
690
770
  }
691
771
  if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey);
772
+ this.cloneUrls.delete(gcKey);
773
+ this.senders.delete(gcKey);
692
774
 
693
775
  log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
694
776
  }
@@ -1005,17 +1087,21 @@ export class Engine {
1005
1087
  log.info(`engine: finishRun aborted (superseded) for ${k}`);
1006
1088
  return;
1007
1089
  }
1008
- const hasRecent = this.hasRecentBotReply(commentsNow, tracker);
1090
+ const hasRecent = this.hasRecentBotReply(commentsNow, tracker, started ?? undefined);
1009
1091
 
1010
1092
  if (hasRecent) {
1011
- log.info(`engine: recent [bot] reply found for ${k}, marking done`);
1093
+ const matched = commentsNow.find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c) && c.createdAt && new Date(c.createdAt).getTime() > (started ?? 0));
1094
+ log.info(`engine: [bot] reply found for ${k} after prompt (comment ${matched?.id ?? "?"} createdAt ${matched?.createdAt ?? "?"}), marking done`);
1012
1095
  this.nudgeRounds.delete(k);
1096
+ this.emptyResponseRounds.delete(k);
1013
1097
  await this.persistRuntimeState(session.id);
1014
1098
  } 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);
1099
+ const sessionOutput = await this.checkSessionOutput(session.opencodeSessionId);
1100
+ const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
1101
+
1102
+ if (!sessionOutput.hasOutput && emptyRound < Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
1103
+ log.warn(`engine: empty model response for ${k} (0 tokens, round ${emptyRound + 1}/${Engine.MAX_EMPTY_RESPONSE_ROUNDS}), retrying`);
1104
+ this.emptyResponseRounds.set(k, emptyRound + 1);
1019
1105
  this.currentPrompt.delete(k);
1020
1106
  await this.persistRuntimeState(session.id);
1021
1107
 
@@ -1025,10 +1111,31 @@ export class Engine {
1025
1111
  await this.dequeueOrIdle(k, session, issue, nudgeMsg);
1026
1112
  return;
1027
1113
  }
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(() => {});
1114
+
1115
+ if (!sessionOutput.hasOutput && emptyRound >= Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
1116
+ log.error(`engine: empty model response for ${k} after ${emptyRound} retries, reporting error`);
1117
+ this.emptyResponseRounds.delete(k);
1118
+ this.nudgeRounds.delete(k);
1119
+ await tracker.createComment(ref, `[system] ❌ **${session.name}** 模型返回空响应(0 token),已重试 ${emptyRound} 次。请检查模型配置或稍后重试。`).catch(() => {});
1120
+ } else {
1121
+ const nudgeRound = this.nudgeRounds.get(k) ?? 0;
1122
+ if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
1123
+ log.info(`engine: no [bot] reply for ${k} (promptTime=${started ?? "unknown"}), nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
1124
+ this.nudgeRounds.set(k, nudgeRound + 1);
1125
+ this.currentPrompt.delete(k);
1126
+ await this.persistRuntimeState(session.id);
1127
+
1128
+ const instructions = tracker.getTrackerInstructions(ref);
1129
+ const nudgePrompt = this.buildNudgePrompt(session, issue, instructions);
1130
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt, undefined, undefined, this.currentModel.get(k));
1131
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
1132
+ return;
1133
+ }
1134
+ log.info(`engine: no [bot] reply for ${k} (promptTime=${started ?? "unknown"}), marking done (nudge exhausted or process failed)`);
1135
+ this.nudgeRounds.delete(k);
1136
+ const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
1137
+ await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
1138
+ }
1032
1139
  }
1033
1140
 
1034
1141
  // Remove eyes on source comment; react +1/-1 on the bot's last reply (fallback: source)
@@ -1619,5 +1726,9 @@ export class Engine {
1619
1726
  this.stuckNudgeRounds.clear();
1620
1727
  this.currentPrompt.clear();
1621
1728
  this.generation.clear();
1729
+ this.groupConfigs.clear();
1730
+ this.cloneUrls.clear();
1731
+ this.senders.clear();
1732
+ this.emptyResponseRounds.clear();
1622
1733
  }
1623
1734
  }
@@ -0,0 +1,129 @@
1
+ import type { PollingTracker, SyncExternalIssue, SyncExternalComment, TrackerRef, PollResult } from "../trackers/types";
2
+ import { log } from "../logger";
3
+
4
+ const PROVENANCE_RE = /<!--\s*sync:(\w+):([^\s:]+)(?::([^\s]+))?\s*-->/;
5
+
6
+ export function withProvenance(sourceType: string, externalId: string, body: string): string {
7
+ const marker = `<!-- sync:${sourceType}:${externalId} -->`;
8
+ return body.endsWith("\n") ? `${body}${marker}` : `${body}\n${marker}`;
9
+ }
10
+
11
+ export function extractProvenance(body: string): { sourceType: string; externalId: string; commentId?: string } | null {
12
+ const m = body.match(PROVENANCE_RE);
13
+ if (!m) return null;
14
+ return { sourceType: m[1]!, externalId: m[2]!, commentId: m[3] };
15
+ }
16
+
17
+ export function isSyncMirrored(body: string, sourceType: string): boolean {
18
+ const p = extractProvenance(body);
19
+ return p?.sourceType === sourceType;
20
+ }
21
+
22
+ export interface SyncEngineOptions {
23
+ tracker: PollingTracker;
24
+ scope: Record<string, string>;
25
+ webUrl: string;
26
+ webToken: string;
27
+ owner: string;
28
+ repo: string;
29
+ pollIntervalMs: number;
30
+ cursorFile: string;
31
+ botLogin: string;
32
+ }
33
+
34
+ interface CursorState {
35
+ issueCursor: string | null;
36
+ commentCursors: Record<string, string | null>;
37
+ issueMap: Record<string, number>;
38
+ }
39
+
40
+ async function loadCursors(path: string): Promise<CursorState> {
41
+ try {
42
+ const f = Bun.file(path);
43
+ if (await f.exists()) return await f.json();
44
+ } catch { /* first run */ }
45
+ return { issueCursor: null, commentCursors: {}, issueMap: {} };
46
+ }
47
+
48
+ async function saveCursors(path: string, state: CursorState): Promise<void> {
49
+ try {
50
+ await Bun.write(path, JSON.stringify(state, null, 2));
51
+ } catch (e) {
52
+ log.warn(`sync: failed to save cursors: ${(e as Error).message}`);
53
+ }
54
+ }
55
+
56
+ export class SyncEngine {
57
+ private opts: SyncEngineOptions;
58
+ private running = false;
59
+ private timer?: ReturnType<typeof setInterval>;
60
+
61
+ constructor(opts: SyncEngineOptions) {
62
+ this.opts = opts;
63
+ }
64
+
65
+ async pollOnce(): Promise<void> {
66
+ const state = await loadCursors(this.opts.cursorFile);
67
+ const { tracker, scope, webUrl, webToken, owner, repo } = this.opts;
68
+
69
+ const issueResult: PollResult<SyncExternalIssue> = await tracker.listChangedIssues(scope, state.issueCursor);
70
+ for (const ext of issueResult.items) {
71
+ const existingLocal = state.issueMap[ext.externalId];
72
+ if (ext.state === "open" && !existingLocal) {
73
+ const body = withProvenance(tracker.type, ext.externalId, ext.body);
74
+ const resp = await fetch(`${webUrl}/api/v1/repos/${owner}/${repo}/issues`, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json", Authorization: `token ${webToken}` },
77
+ body: JSON.stringify({ title: ext.title, body, assignee: ext.author }),
78
+ });
79
+ if (resp.ok) {
80
+ const data = await resp.json() as { number: number };
81
+ state.issueMap[ext.externalId] = data.number;
82
+ log.info(`sync: created issue #${data.number} from ${tracker.type}:${ext.externalId}`);
83
+ } else {
84
+ log.warn(`sync: create issue failed: ${resp.status} ${await resp.text()}`);
85
+ }
86
+ }
87
+ }
88
+ state.issueCursor = issueResult.nextCursor ?? state.issueCursor;
89
+
90
+ for (const [extId, issueNum] of Object.entries(state.issueMap)) {
91
+ const ref: TrackerRef = { trackerType: tracker.type, scope, issueId: String(issueNum) };
92
+ const cCursor = state.commentCursors[extId] ?? null;
93
+ const cResult: PollResult<SyncExternalComment> = await tracker.listChangedComments(ref, cCursor);
94
+ for (const c of cResult.items) {
95
+ if (tracker.isBotUser(c.author)) continue;
96
+ const body = withProvenance(tracker.type, extId, c.body);
97
+ const resp = await fetch(`${webUrl}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`, {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json", Authorization: `token ${webToken}` },
100
+ body: JSON.stringify({ body }),
101
+ });
102
+ if (resp.ok) {
103
+ log.info(`sync: created comment on issue #${issueNum} from ${tracker.type}:${extId}:${c.externalId}`);
104
+ } else {
105
+ log.warn(`sync: create comment failed: ${resp.status}`);
106
+ }
107
+ }
108
+ state.commentCursors[extId] = cResult.nextCursor ?? cCursor;
109
+ }
110
+
111
+ await saveCursors(this.opts.cursorFile, state);
112
+ }
113
+
114
+ start(): void {
115
+ if (this.running) return;
116
+ this.running = true;
117
+ log.info(`sync: poll loop started (interval=${this.opts.pollIntervalMs}ms, source=${this.opts.tracker.type})`);
118
+ this.pollOnce().catch(e => log.error(`sync: poll error: ${(e as Error).message}`));
119
+ this.timer = setInterval(() => {
120
+ this.pollOnce().catch(e => log.error(`sync: poll error: ${(e as Error).message}`));
121
+ }, this.opts.pollIntervalMs);
122
+ }
123
+
124
+ stop(): void {
125
+ this.running = false;
126
+ if (this.timer) clearInterval(this.timer);
127
+ log.info("sync: poll loop stopped");
128
+ }
129
+ }
@@ -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 */
@@ -135,6 +141,34 @@ export interface IssueTracker {
135
141
  isBotUser(userIdentifier: string): boolean;
136
142
  }
137
143
 
144
+ // ─── Polling Tracker (Sync Source) ───
145
+
146
+ export interface SyncExternalIssue {
147
+ externalId: string;
148
+ title: string;
149
+ body: string;
150
+ state: "open" | "closed";
151
+ author: string;
152
+ updatedAt: string;
153
+ }
154
+
155
+ export interface SyncExternalComment {
156
+ externalId: string;
157
+ body: string;
158
+ author: string;
159
+ createdAt: string;
160
+ }
161
+
162
+ export interface PollResult<T> {
163
+ items: T[];
164
+ nextCursor: string | null;
165
+ }
166
+
167
+ export interface PollingTracker extends IssueTracker {
168
+ listChangedIssues(scope: Record<string, string>, cursor: string | null): Promise<PollResult<SyncExternalIssue>>;
169
+ listChangedComments(ref: TrackerRef, cursor: string | null): Promise<PollResult<SyncExternalComment>>;
170
+ }
171
+
138
172
  // ─── Runtime Key Format ───
139
173
 
140
174
  /**