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 +1 -1
- package/src/opencode.ts +100 -47
- package/src/sync/engine.ts +129 -0
- package/src/trackers/types.ts +28 -0
package/package.json
CHANGED
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/trackers/types.ts
CHANGED
|
@@ -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
|
/**
|