ework-daemon 0.4.15 → 0.4.17

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.15",
3
+ "version": "0.4.17",
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
@@ -22,7 +22,7 @@ interface TrackerRegistry {
22
22
  * the coordination layer.
23
23
  */
24
24
  export interface TakeoverStrategy {
25
- acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string): Promise<string>;
25
+ acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string, env?: Record<string, string>): Promise<string>;
26
26
  resumeOpenCodeSession(session: OpSession): Promise<string | null>;
27
27
  }
28
28
 
@@ -91,6 +91,85 @@ export async function runHookScript(script: string | undefined, workdir: string,
91
91
  }
92
92
  }
93
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
+
156
+ export async function opencodeSessionExists(dbPath: string, sessionId: string): Promise<boolean> {
157
+ let db: Database;
158
+ try {
159
+ db = new Database(dbPath, { readonly: true });
160
+ } catch {
161
+ return false;
162
+ }
163
+ try {
164
+ const row = db.prepare("SELECT 1 FROM session WHERE id = ? LIMIT 1").get(sessionId);
165
+ return !!row;
166
+ } catch {
167
+ return false;
168
+ } finally {
169
+ db.close();
170
+ }
171
+ }
172
+
94
173
  /**
95
174
  * Default TakeoverStrategy: deterministic per-issue workdir under
96
175
  * `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
@@ -100,7 +179,7 @@ export async function runHookScript(script: string | undefined, workdir: string,
100
179
  export class RecloneStrategy implements TakeoverStrategy {
101
180
  constructor(private cfg: Config) {}
102
181
 
103
- async acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string): Promise<string> {
182
+ async acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string, env?: Record<string, string>): Promise<string> {
104
183
  if (session.workdir) {
105
184
  let dir = session.workdir;
106
185
  if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
@@ -126,7 +205,7 @@ export class RecloneStrategy implements TakeoverStrategy {
126
205
  const gitArgs = ["git"];
127
206
  if (credHelper) gitArgs.push("-c", `credential.helper=${credHelper}`);
128
207
  gitArgs.push("clone", url, dir);
129
- const r = Bun.spawnSync({ cmd: gitArgs, stdout: "ignore", stderr: "pipe" });
208
+ const r = Bun.spawnSync({ cmd: gitArgs, stdout: "ignore", stderr: "pipe", env: { ...process.env, ...env } });
130
209
  if (r.exitCode !== 0) {
131
210
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
132
211
  if (existsSync(dir) && readdirSync(dir).length === 0) {
@@ -338,7 +417,16 @@ export class Engine {
338
417
  }
339
418
  const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
340
419
  const cloneUrl = this.cloneUrls.get(issueMapKey);
341
- return this.takeover.acquireWorkdir(session, issue, cloneUrl);
420
+ const owner = String(issue.trackerScope["owner"] ?? issue.trackerScopeKey.split("/")[0] ?? "");
421
+ const repo = String(issue.trackerScope["repo"] ?? issue.trackerScopeKey.split("/").slice(-1)[0] ?? "");
422
+ const sender = this.senders.get(issueMapKey);
423
+ const env: Record<string, string> = {
424
+ EWORK_OWNER: owner,
425
+ EWORK_REPO: repo,
426
+ EWORK_ISSUE: String(issue.trackerIssueId),
427
+ };
428
+ if (sender) env.EWORK_SENDER = sender;
429
+ return this.takeover.acquireWorkdir(session, issue, cloneUrl, env);
342
430
  }
343
431
 
344
432
  private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
@@ -423,12 +511,8 @@ export class Engine {
423
511
  return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
424
512
  }
425
513
 
426
- /** System comments are posted by the daemon itself (acks, progress, reports). They are NOT AI replies. */
427
- private static SYSTEM_PREFIX = "[system]";
428
- private static RECENT_BOT_REPLY_THRESHOLD_MS = 5 * 60_000; // 5 minutes
429
-
430
514
  private isSystemComment(comment: TrackerComment): boolean {
431
- return comment.body.startsWith(Engine.SYSTEM_PREFIX);
515
+ return comment.body.startsWith(SYSTEM_PREFIX);
432
516
  }
433
517
 
434
518
  private countAIReplies(comments: TrackerComment[], tracker: IssueTracker): number {
@@ -436,25 +520,7 @@ export class Engine {
436
520
  }
437
521
 
438
522
  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)
451
- const now = Date.now();
452
- return comments.some(c => {
453
- if (!tracker.isBotUser(c.author) || this.isSystemComment(c)) return false;
454
- if (!c.createdAt) return true;
455
- const age = now - new Date(c.createdAt).getTime();
456
- return age < Engine.RECENT_BOT_REPLY_THRESHOLD_MS;
457
- });
523
+ return hasRecentBotReply(comments, (a) => tracker.isBotUser(a), promptTime);
458
524
  }
459
525
 
460
526
  private lastBotReply(comments: TrackerComment[], tracker: IssueTracker): TrackerComment | undefined {
@@ -462,25 +528,7 @@ export class Engine {
462
528
  }
463
529
 
464
530
  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
- }
531
+ return checkSessionOutput(this.cfg.opencode.dbPath, opencodeSessionId);
484
532
  }
485
533
 
486
534
  // ─── Event Dispatch ───
@@ -854,6 +902,11 @@ export class Engine {
854
902
  const fromStrategy = await this.takeover.resumeOpenCodeSession(session);
855
903
  if (fromStrategy) resumeSessionId = fromStrategy;
856
904
  }
905
+ if (resumeSessionId && !(await opencodeSessionExists(this.cfg.opencode.dbPath, resumeSessionId))) {
906
+ log.warn(`stale opencode session ${resumeSessionId} not found in db, starting fresh`);
907
+ resumeSessionId = undefined;
908
+ await this.store.updateSession(session.id, { opencodeSessionId: undefined });
909
+ }
857
910
  if (resumeSessionId) {
858
911
  args.push("--session", resumeSessionId);
859
912
  }
@@ -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
+ }
@@ -141,6 +141,34 @@ export interface IssueTracker {
141
141
  isBotUser(userIdentifier: string): boolean;
142
142
  }
143
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
+
144
172
  // ─── Runtime Key Format ───
145
173
 
146
174
  /**