ework-daemon 0.4.25 → 0.4.27

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.25",
3
+ "version": "0.4.27",
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/op.ts CHANGED
@@ -230,25 +230,27 @@ export class Store {
230
230
  const existing = rowToSession(row);
231
231
  const updated = { ...existing, ...patch };
232
232
 
233
+ const sets: string[] = [];
234
+ const vals: (string | number | null)[] = [];
235
+ if (updated.state !== undefined) { sets.push("state = ?"); vals.push(updated.state); }
236
+ if (updated.opencodeSessionId !== undefined) { sets.push("opencode_session_id = ?"); vals.push(updated.opencodeSessionId ?? null); }
237
+ if (updated.opencodePid !== undefined) { sets.push("opencode_pid = ?"); vals.push(updated.opencodePid ?? null); }
238
+ if (updated.workdir !== undefined) { sets.push("workdir = ?"); vals.push(updated.workdir ?? null); }
239
+ if (updated.startedAt !== undefined) { sets.push("started_at = ?"); vals.push(updated.startedAt ?? null); }
240
+ if (updated.progressCommentId !== undefined) { sets.push("progress_comment_id = ?"); vals.push(updated.progressCommentId ?? null); }
241
+ if (updated.reactionCommentId !== undefined) { sets.push("reaction_comment_id = ?"); vals.push(updated.reactionCommentId ?? null); }
242
+ if (updated.currentPrompt !== undefined) { sets.push("current_prompt = ?"); vals.push(updated.currentPrompt ?? null); }
243
+ if (updated.lastOutputAt !== undefined) { sets.push("last_output_at = ?"); vals.push(updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null); }
244
+ if (updated.nudgeRounds !== undefined) { sets.push("nudge_rounds = ?"); vals.push(updated.nudgeRounds); }
245
+ if (updated.stuckNudgeRounds !== undefined) { sets.push("stuck_nudge_rounds = ?"); vals.push(updated.stuckNudgeRounds); }
246
+ if (updated.generation !== undefined) { sets.push("generation = ?"); vals.push(updated.generation); }
247
+
248
+ if (sets.length === 0) return updated;
249
+
250
+ vals.push(id);
233
251
  await getDB().run(
234
- `UPDATE {{op_sessions}} SET state = ?, opencode_session_id = ?, opencode_pid = ?, workdir = ?,
235
- started_at = ?, progress_comment_id = ?, reaction_comment_id = ?, current_prompt = ?,
236
- last_output_at = ?, nudge_rounds = ?, stuck_nudge_rounds = ?, generation = ? WHERE uid = ?`,
237
- [
238
- updated.state,
239
- updated.opencodeSessionId ?? null,
240
- updated.opencodePid ?? null,
241
- updated.workdir ?? null,
242
- updated.startedAt ?? null,
243
- updated.progressCommentId ?? null,
244
- updated.reactionCommentId ?? null,
245
- updated.currentPrompt ?? null,
246
- updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null,
247
- updated.nudgeRounds ?? 0,
248
- updated.stuckNudgeRounds ?? 0,
249
- updated.generation ?? 0,
250
- id,
251
- ]
252
+ `UPDATE {{op_sessions}} SET ${sets.join(", ")} WHERE uid = ?`,
253
+ vals
252
254
  );
253
255
  return updated;
254
256
  }
package/src/opencode.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { spawn, type Subprocess } from "bun";
2
1
  import { Database } from "bun:sqlite";
3
2
  import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
4
3
  import { join, resolve, isAbsolute } from "path";
@@ -8,6 +7,8 @@ import type { Config } from "./config";
8
7
  import type { Store } from "./op";
9
8
  import type { IssueTracker, TrackerRef, TrackerEvent, TrackerComment, Issue, OpSession, Message } from "./trackers/types";
10
9
  import { formatKey, parseKey } from "./trackers/types";
10
+ import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
11
+ import { OpencodeBackend } from "./runtime/opencode-backend";
11
12
 
12
13
  // ─── Types ───
13
14
 
@@ -282,6 +283,8 @@ export interface EngineOptions {
282
283
  daemonId: number;
283
284
  /** Workdir + session-resume strategy; defaults to RecloneStrategy. */
284
285
  takeover?: TakeoverStrategy;
286
+ /** Runtime backend (opencode/pi); defaults to OpencodeBackend. */
287
+ backend?: RuntimeBackend;
285
288
  }
286
289
 
287
290
  export class Engine {
@@ -290,10 +293,11 @@ export class Engine {
290
293
  private trackers: TrackerRegistry;
291
294
  private readonly daemonId: number;
292
295
  private readonly takeover: TakeoverStrategy;
296
+ private readonly backend: RuntimeBackend;
293
297
  private heartbeatTimer?: ReturnType<typeof setInterval>;
294
298
 
295
299
  // Runtime state keyed by session key (trackerType:scopeKey#issueId@sessionName)
296
- private processes = new Map<string, Subprocess<"ignore", "pipe", "pipe">>();
300
+ private processes = new Map<string, RuntimeHandle>();
297
301
  private running = new Set<string>();
298
302
  private stopping = new Set<string>();
299
303
  private processingComments = new Set<string>();
@@ -338,6 +342,7 @@ export class Engine {
338
342
  this.trackers = trackers;
339
343
  this.daemonId = opts.daemonId;
340
344
  this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
345
+ this.backend = opts.backend ?? new OpencodeBackend(cfg.opencode.binary, cfg.opencode.dbPath, cfg.childEnvDeny);
341
346
  this.startGlobalObserver();
342
347
  void this.recover();
343
348
  }
@@ -545,10 +550,6 @@ export class Engine {
545
550
  return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
546
551
  }
547
552
 
548
- private async checkSessionOutput(opencodeSessionId: string | undefined): Promise<{ hasOutput: boolean; tokenCount: number }> {
549
- return checkSessionOutput(this.cfg.opencode.dbPath, opencodeSessionId);
550
- }
551
-
552
553
  // ─── Event Dispatch ───
553
554
 
554
555
  async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
@@ -966,129 +967,69 @@ export class Engine {
966
967
  const ref = this.sessionToRef(session, issue);
967
968
  const tracker = this.getTracker(issue.trackerType);
968
969
 
969
- const args = [this.cfg.opencode.binary, "run", "--format", "json", "--dir", workdir];
970
- // Prefer the captured session id (resume our own previous run). Otherwise
971
- // ask the takeover strategy whether a resumable session exists elsewhere
972
- // (e.g. NAS-backed). Default strategy returns null → fresh session.
973
970
  let resumeSessionId = session.opencodeSessionId;
974
971
  if (!resumeSessionId) {
975
972
  const fromStrategy = await this.takeover.resumeOpenCodeSession(session);
976
973
  if (fromStrategy) resumeSessionId = fromStrategy;
977
974
  }
978
- if (resumeSessionId && !(await opencodeSessionExists(this.cfg.opencode.dbPath, resumeSessionId))) {
979
- log.warn(`stale opencode session ${resumeSessionId} not found in db, starting fresh`);
975
+ if (resumeSessionId && !(await this.backend.sessionExists(resumeSessionId))) {
976
+ log.warn(`stale session ${resumeSessionId} not found in db, starting fresh`);
980
977
  resumeSessionId = undefined;
981
978
  await this.store.updateSession(session.id, { opencodeSessionId: undefined });
982
979
  }
983
- if (resumeSessionId) {
984
- args.push("--session", resumeSessionId);
985
- }
986
- // Resolve the model: explicit per-message > daemon default > omit.
987
- // Without this, opencode's own default (influenced by plugin agent
988
- // presets) can pick a non-existent model, causing ProviderModelNotFoundError.
980
+
989
981
  const model = msg.model || this.cfg.opencode.defaultModel;
990
982
  this.currentModel.set(k, model);
991
- if (model) {
992
- args.push("--model", model);
993
- }
994
- args.push(msg.content);
995
983
 
996
- // Set eyes reaction on the source comment
997
984
  if (msg.sourceCommentId) {
998
- try {
999
- await tracker.setReaction(ref, msg.sourceCommentId, "eyes");
1000
- } catch { /* non-critical */ }
985
+ try { await tracker.setReaction(ref, msg.sourceCommentId, "eyes"); } catch { /* non-critical */ }
1001
986
  }
1002
987
 
1003
- let exitCode: number | null = null;
1004
-
1005
- // M-1: build the child env explicitly. When a model IS resolved we push
1006
- // --model above, so opencode ignores OPENCODE_MODEL anyway. When NO model
1007
- // is resolved we must not let a leaked OPENCODE_MODEL from our own env
1008
- // silently win — strip it so opencode falls back to its opencode.json
1009
- // (the operator's explicit config) instead of arbitrary env pollution.
1010
- // Provider keys / Gitea vars are preserved (opencode + its plugin need
1011
- // them); only the explicit model-override var is neutralized.
1012
988
  const childEnv = { ...process.env, ...this.hookEnvFor(issue, session, workdir) };
1013
- if (!model) delete childEnv.OPENCODE_MODEL;
1014
- delete childEnv.OPENCODE;
1015
- delete childEnv.OPENCODE_PID;
1016
- delete childEnv.OPENCODE_RUN_ID;
1017
- delete childEnv.OPENCODE_PROCESS_ROLE;
1018
- for (const k of this.cfg.childEnvDeny) delete childEnv[k];
989
+
990
+ let exitCode: number | null = null;
1019
991
 
1020
992
  try {
1021
- const proc = spawn({
1022
- cmd: args,
1023
- cwd: workdir,
1024
- env: childEnv,
1025
- stdout: "pipe",
1026
- stderr: "pipe",
1027
- stdin: "ignore",
1028
- });
993
+ const handle = await this.backend.spawn(
994
+ {
995
+ workdir,
996
+ prompt: msg.content,
997
+ model: model || undefined,
998
+ resumeSessionId: resumeSessionId || undefined,
999
+ env: childEnv,
1000
+ },
1001
+ {
1002
+ onOutput: () => { this.lastOutputAt.set(k, Date.now()); },
1003
+ onSessionId: async (id: string) => {
1004
+ if (!session.opencodeSessionId) {
1005
+ await this.store.updateSession(session.id, { opencodeSessionId: id });
1006
+ session.opencodeSessionId = id;
1007
+ log.info(`engine: captured sessionID=${id.slice(0, 8)} for ${k} (early persist)`);
1008
+ }
1009
+ },
1010
+ },
1011
+ );
1029
1012
 
1030
1013
  if (this.generation.get(k) !== gen) {
1031
- log.warn(`engine: spawned pid=${proc.pid} but generation superseded — killing orphan for ${k}`);
1032
- try { this.killProcessTree(proc.pid); } catch { /* already dead */ }
1014
+ log.warn(`engine: spawned pid=${handle.pid} but generation superseded — killing orphan for ${k}`);
1015
+ try { this.killProcessTree(handle.pid); } catch { /* already dead */ }
1033
1016
  return;
1034
1017
  }
1035
1018
 
1036
- this.processes.set(k, proc);
1019
+ this.processes.set(k, handle);
1037
1020
  this.lastOutputAt.set(k, Date.now());
1038
- if (!this.startedAt.has(k)) {
1039
- this.startedAt.set(k, Date.now());
1040
- }
1021
+ if (!this.startedAt.has(k)) this.startedAt.set(k, Date.now());
1041
1022
  this.currentPrompt.set(k, msg.content);
1042
1023
 
1043
- // Persist PID for crash recovery
1044
- await this.store.updateSession(session.id, { opencodePid: proc.pid });
1024
+ await this.store.updateSession(session.id, { opencodePid: handle.pid });
1045
1025
  await this.persistRuntimeState(session.id);
1046
1026
 
1047
- log.info(`engine: spawned pid=${proc.pid} for ${k}`);
1048
-
1049
- // Read stdout to capture session ID
1050
- let opencodeSessionId: string | null = null;
1051
- const stderrPromise = new Response(proc.stderr).text();
1052
- const reader = proc.stdout.getReader();
1053
- const decoder = new TextDecoder();
1054
- let lineBuf = "";
1055
-
1056
- while (true) {
1057
- const { done, value } = await reader.read();
1058
- if (done) break;
1059
-
1060
- this.lastOutputAt.set(k, Date.now());
1061
-
1062
- if (!opencodeSessionId) {
1063
- lineBuf += decoder.decode(value, { stream: true });
1064
- const lines = lineBuf.split("\n");
1065
- lineBuf = lines.pop()!;
1066
- for (const line of lines) {
1067
- if (!line.trim()) continue;
1068
- try {
1069
- const ev = JSON.parse(line);
1070
- if (ev.sessionID) {
1071
- const sid: string = ev.sessionID;
1072
- opencodeSessionId = sid;
1073
- // Persist now, not at exit: a preempt/crash before exit must
1074
- // not lose the ID, otherwise the re-run opens a fresh session.
1075
- if (!session.opencodeSessionId) {
1076
- await this.store.updateSession(session.id, { opencodeSessionId: sid });
1077
- session.opencodeSessionId = sid;
1078
- log.info(`engine: captured sessionID=${sid.slice(0, 8)} for ${k} (early persist)`);
1079
- }
1080
- break;
1081
- }
1082
- } catch { /* not json */ }
1083
- }
1084
- }
1085
- }
1027
+ log.info(`engine: spawned pid=${handle.pid} for ${k} (backend=${this.backend.name})`);
1086
1028
 
1087
- exitCode = await proc.exited;
1088
- const stderr = await stderrPromise;
1029
+ exitCode = await handle.exited;
1030
+ const stderr = await handle.stderrText;
1089
1031
 
1090
- // Detect preemption or force-stop: if our process is no longer in the map, another took over
1091
- if (this.processes.get(k) !== proc) {
1032
+ if (this.processes.get(k) !== handle) {
1092
1033
  log.info(`engine: process replaced, skipping finishRun for ${k}`);
1093
1034
  this.stopping.delete(k);
1094
1035
  return;
@@ -1099,20 +1040,15 @@ export class Engine {
1099
1040
  await this.store.updateSession(session.id, { opencodePid: undefined });
1100
1041
 
1101
1042
  if (exitCode !== 0) {
1102
- log.error(`engine: pid=${proc.pid} exited ${exitCode} for ${k}`);
1043
+ log.error(`engine: pid=${handle.pid} exited ${exitCode} for ${k}`);
1103
1044
  log.error(` stderr: ${stderr.slice(0, 2000)}`);
1104
1045
  await this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
1105
1046
  } else {
1106
- log.info(`engine: pid=${proc.pid} completed for ${k}`);
1107
- if (stderr) log.warn(`engine: pid=${proc.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
1108
- if (!opencodeSessionId) log.warn(`engine: pid=${proc.pid} produced NO sessionID (no stdout output)`);
1047
+ log.info(`engine: pid=${handle.pid} completed for ${k}`);
1048
+ if (stderr) log.warn(`engine: pid=${handle.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
1049
+ if (!session.opencodeSessionId) log.warn(`engine: pid=${handle.pid} produced NO sessionID (no stdout output)`);
1109
1050
  await this.store.updateMessageStatus(msg.id, "done");
1110
1051
  }
1111
-
1112
- // Save opencode session ID for continuity
1113
- if (opencodeSessionId && !session.opencodeSessionId) {
1114
- await this.store.updateSession(session.id, { opencodeSessionId });
1115
- }
1116
1052
  } catch (err) {
1117
1053
  log.error(`engine: exec failed for ${k}:`, err);
1118
1054
  await this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
@@ -1198,7 +1134,7 @@ export class Engine {
1198
1134
  await this.persistRuntimeState(session.id);
1199
1135
  void tracker.updateStatus(ref, "");
1200
1136
  } else {
1201
- const sessionOutput = await this.checkSessionOutput(session.opencodeSessionId);
1137
+ const sessionOutput = await this.backend.getSessionOutputTokens(session.opencodeSessionId);
1202
1138
  const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
1203
1139
 
1204
1140
  if (!sessionOutput.hasOutput && emptyRound < Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
@@ -0,0 +1,129 @@
1
+ import { spawn } from "bun";
2
+ import { Database } from "bun:sqlite";
3
+ import { log } from "../logger";
4
+ import type {
5
+ RuntimeBackend,
6
+ RuntimeSpawnOpts,
7
+ RuntimeSpawnCallbacks,
8
+ RuntimeHandle,
9
+ SessionOutputResult,
10
+ } from "./types";
11
+
12
+ const ENV_DENY_ALWAYS = ["OPENCODE", "OPENCODE_PID", "OPENCODE_RUN_ID", "OPENCODE_PROCESS_ROLE"] as const;
13
+
14
+ export class OpencodeBackend implements RuntimeBackend {
15
+ readonly name = "opencode";
16
+
17
+ constructor(
18
+ private binary: string,
19
+ private dbPath: string,
20
+ private childEnvDeny: string[] = [],
21
+ ) {}
22
+
23
+ async spawn(opts: RuntimeSpawnOpts, cb: RuntimeSpawnCallbacks): Promise<RuntimeHandle> {
24
+ const args = [this.binary, "run", "--format", "json", "--dir", opts.workdir];
25
+
26
+ if (opts.resumeSessionId) {
27
+ args.push("--session", opts.resumeSessionId);
28
+ }
29
+ if (opts.model) {
30
+ args.push("--model", opts.model);
31
+ }
32
+ args.push(opts.prompt);
33
+
34
+ const childEnv = { ...opts.env };
35
+ if (!opts.model) delete childEnv.OPENCODE_MODEL;
36
+ for (const key of ENV_DENY_ALWAYS) delete childEnv[key];
37
+ for (const key of this.childEnvDeny) delete childEnv[key];
38
+
39
+ const proc = spawn({
40
+ cmd: args,
41
+ cwd: opts.workdir,
42
+ env: childEnv,
43
+ stdout: "pipe",
44
+ stderr: "pipe",
45
+ stdin: "ignore",
46
+ });
47
+
48
+ const stderrText = new Response(proc.stderr).text();
49
+
50
+ void this.readStdout(proc, cb);
51
+
52
+ return { pid: proc.pid, exited: proc.exited, stderrText };
53
+ }
54
+
55
+ private async readStdout(
56
+ proc: ReturnType<typeof spawn>,
57
+ cb: RuntimeSpawnCallbacks,
58
+ ): Promise<void> {
59
+ let captured = false;
60
+ const stdout = proc.stdout;
61
+ if (!stdout || typeof stdout === "number") return;
62
+ const reader = stdout.getReader();
63
+ const decoder = new TextDecoder();
64
+ let lineBuf = "";
65
+
66
+ while (true) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+
70
+ cb.onOutput();
71
+
72
+ if (!captured) {
73
+ lineBuf += decoder.decode(value, { stream: true });
74
+ const lines = lineBuf.split("\n");
75
+ lineBuf = lines.pop()!;
76
+ for (const line of lines) {
77
+ if (!line.trim()) continue;
78
+ try {
79
+ const ev = JSON.parse(line);
80
+ if (ev.sessionID) {
81
+ await cb.onSessionId(ev.sessionID as string);
82
+ captured = true;
83
+ break;
84
+ }
85
+ } catch { /* not json */ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ async sessionExists(sessionId: string): Promise<boolean> {
92
+ let db: Database;
93
+ try {
94
+ db = new Database(this.dbPath, { readonly: true });
95
+ } catch {
96
+ return false;
97
+ }
98
+ try {
99
+ const row = db.prepare("SELECT 1 FROM session WHERE id = ? LIMIT 1").get(sessionId);
100
+ return !!row;
101
+ } catch {
102
+ return false;
103
+ } finally {
104
+ db.close();
105
+ }
106
+ }
107
+
108
+ async getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult> {
109
+ if (!sessionId) return { hasOutput: true, tokenCount: 0 };
110
+ let db: Database;
111
+ try {
112
+ db = new Database(this.dbPath, { readonly: true });
113
+ } catch {
114
+ return { hasOutput: true, tokenCount: 0 };
115
+ }
116
+ try {
117
+ const row = db.prepare(
118
+ "SELECT COUNT(*) AS n, COALESCE(SUM(CAST(json_extract(data,'$.tokens.output') AS INT)), 0) AS tokens " +
119
+ "FROM message WHERE session_id = ? AND json_extract(data,'$.role') = 'assistant'"
120
+ ).get(sessionId) as { n: number; tokens: number } | null;
121
+ if (!row) return { hasOutput: true, tokenCount: 0 };
122
+ return { hasOutput: row.n > 0 && row.tokens > 0, tokenCount: row.tokens };
123
+ } catch {
124
+ return { hasOutput: true, tokenCount: 0 };
125
+ } finally {
126
+ db.close();
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,33 @@
1
+ export interface RuntimeSpawnOpts {
2
+ workdir: string;
3
+ prompt: string;
4
+ model?: string;
5
+ resumeSessionId?: string;
6
+ env: Record<string, string | undefined>;
7
+ }
8
+
9
+ export interface RuntimeSpawnCallbacks {
10
+ onOutput: () => void;
11
+ onSessionId: (id: string) => Promise<void> | void;
12
+ }
13
+
14
+ export interface RuntimeHandle {
15
+ pid: number;
16
+ exited: Promise<number>;
17
+ stderrText: Promise<string>;
18
+ }
19
+
20
+ export interface SessionOutputResult {
21
+ hasOutput: boolean;
22
+ tokenCount: number;
23
+ }
24
+
25
+ export interface RuntimeBackend {
26
+ readonly name: string;
27
+
28
+ spawn(opts: RuntimeSpawnOpts, callbacks: RuntimeSpawnCallbacks): Promise<RuntimeHandle>;
29
+
30
+ sessionExists(sessionId: string): Promise<boolean>;
31
+
32
+ getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult>;
33
+ }