ework-daemon 0.4.15 → 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.15",
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
@@ -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,68 @@ 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
+
94
156
  /**
95
157
  * Default TakeoverStrategy: deterministic per-issue workdir under
96
158
  * `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
@@ -100,7 +162,7 @@ export async function runHookScript(script: string | undefined, workdir: string,
100
162
  export class RecloneStrategy implements TakeoverStrategy {
101
163
  constructor(private cfg: Config) {}
102
164
 
103
- async acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string): Promise<string> {
165
+ async acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string, env?: Record<string, string>): Promise<string> {
104
166
  if (session.workdir) {
105
167
  let dir = session.workdir;
106
168
  if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
@@ -126,7 +188,7 @@ export class RecloneStrategy implements TakeoverStrategy {
126
188
  const gitArgs = ["git"];
127
189
  if (credHelper) gitArgs.push("-c", `credential.helper=${credHelper}`);
128
190
  gitArgs.push("clone", url, dir);
129
- const r = Bun.spawnSync({ cmd: gitArgs, stdout: "ignore", stderr: "pipe" });
191
+ const r = Bun.spawnSync({ cmd: gitArgs, stdout: "ignore", stderr: "pipe", env: { ...process.env, ...env } });
130
192
  if (r.exitCode !== 0) {
131
193
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
132
194
  if (existsSync(dir) && readdirSync(dir).length === 0) {
@@ -338,7 +400,16 @@ export class Engine {
338
400
  }
339
401
  const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
340
402
  const cloneUrl = this.cloneUrls.get(issueMapKey);
341
- return this.takeover.acquireWorkdir(session, issue, cloneUrl);
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);
342
413
  }
343
414
 
344
415
  private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
@@ -423,12 +494,8 @@ export class Engine {
423
494
  return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
424
495
  }
425
496
 
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
497
  private isSystemComment(comment: TrackerComment): boolean {
431
- return comment.body.startsWith(Engine.SYSTEM_PREFIX);
498
+ return comment.body.startsWith(SYSTEM_PREFIX);
432
499
  }
433
500
 
434
501
  private countAIReplies(comments: TrackerComment[], tracker: IssueTracker): number {
@@ -436,25 +503,7 @@ export class Engine {
436
503
  }
437
504
 
438
505
  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
- });
506
+ return hasRecentBotReply(comments, (a) => tracker.isBotUser(a), promptTime);
458
507
  }
459
508
 
460
509
  private lastBotReply(comments: TrackerComment[], tracker: IssueTracker): TrackerComment | undefined {
@@ -462,25 +511,7 @@ export class Engine {
462
511
  }
463
512
 
464
513
  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
- }
514
+ return checkSessionOutput(this.cfg.opencode.dbPath, opencodeSessionId);
484
515
  }
485
516
 
486
517
  // ─── Event Dispatch ───
@@ -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
  /**