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 +1 -1
- package/src/opencode.ts +78 -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,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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
/**
|