ework-daemon 0.4.57 → 0.4.59

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.57",
3
+ "version": "0.4.59",
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/config.ts CHANGED
@@ -72,6 +72,7 @@ export const configSchema = z.object({
72
72
  stuck: z.object({
73
73
  thresholdMs: z.coerce.number().positive(),
74
74
  maxNudges: z.coerce.number().int().nonnegative(),
75
+ maxRuntimeMs: z.coerce.number().positive(),
75
76
  }).optional(),
76
77
  file: z.object({
77
78
  roots: z.array(z.string()).default([]),
@@ -170,9 +171,10 @@ export function loadConfig(): Config {
170
171
  baseURL: process.env.COMPLETION_CHECK_BASE_URL ?? "",
171
172
  model: process.env.COMPLETION_CHECK_MODEL ?? "",
172
173
  } : undefined,
173
- stuck: process.env.DAEMON_STUCK_THRESHOLD_MS || process.env.DAEMON_MAX_STUCK_NUDGES ? {
174
+ stuck: process.env.DAEMON_STUCK_THRESHOLD_MS || process.env.DAEMON_MAX_STUCK_NUDGES || process.env.DAEMON_STUCK_MAX_RUNTIME_MS ? {
174
175
  thresholdMs: Number(process.env.DAEMON_STUCK_THRESHOLD_MS) || 30 * 60 * 1000,
175
176
  maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
177
+ maxRuntimeMs: Number(process.env.DAEMON_STUCK_MAX_RUNTIME_MS) || 3 * 60 * 60 * 1000,
176
178
  } : undefined,
177
179
  childEnvDeny: (process.env.WORK_CHILD_ENV_DENY ?? "").split(",").map((s) => s.trim()).filter(Boolean),
178
180
  });
@@ -217,9 +219,10 @@ export function loadConfig(): Config {
217
219
  baseURL: process.env.COMPLETION_CHECK_BASE_URL ?? "",
218
220
  model: process.env.COMPLETION_CHECK_MODEL ?? "",
219
221
  } : undefined,
220
- stuck: process.env.DAEMON_STUCK_THRESHOLD_MS || process.env.DAEMON_MAX_STUCK_NUDGES ? {
222
+ stuck: process.env.DAEMON_STUCK_THRESHOLD_MS || process.env.DAEMON_MAX_STUCK_NUDGES || process.env.DAEMON_STUCK_MAX_RUNTIME_MS ? {
221
223
  thresholdMs: Number(process.env.DAEMON_STUCK_THRESHOLD_MS) || 30 * 60 * 1000,
222
224
  maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
225
+ maxRuntimeMs: Number(process.env.DAEMON_STUCK_MAX_RUNTIME_MS) || 3 * 60 * 60 * 1000,
223
226
  } : undefined,
224
227
  file: {
225
228
  roots: (process.env.WORK_FILE_ROOTS ?? "").split(":").filter(Boolean).length > 0
package/src/opencode.ts CHANGED
@@ -309,6 +309,13 @@ function createDefaultBackend(cfg: Config): RuntimeBackend {
309
309
  return new OpencodeBackend(cfg.opencode.binary, cfg.opencode.dbPath, cfg.childEnvDeny);
310
310
  }
311
311
 
312
+ function createBackendFor(cfg: Config, runtime: string): RuntimeBackend {
313
+ if (runtime === "pi" && cfg.pi) {
314
+ return new PiBackend(cfg.pi.binary, cfg.pi.provider, cfg.pi.defaultModel, cfg.childEnvDeny);
315
+ }
316
+ return new OpencodeBackend(cfg.opencode.binary, cfg.opencode.dbPath, cfg.childEnvDeny);
317
+ }
318
+
312
319
  export class Engine {
313
320
  private cfg: Config;
314
321
  private store: Store;
@@ -352,6 +359,11 @@ export class Engine {
352
359
 
353
360
  private groupConfigs = new Map<string, GroupConfig>();
354
361
  private cloneUrls = new Map<string, string>();
362
+ // Per-issue runtime override ("opencode"|"pi") from webhook payloads.
363
+ // Existing sessions stay pinned to their original backend via the
364
+ // opencodeSessionId prefix (ses_=opencode, bare uuid=pi) in backendFor().
365
+ private issueRuntimes = new Map<string, string>();
366
+ private altBackend?: RuntimeBackend;
355
367
  private senders = new Map<string, string>();
356
368
  private envInitialized = new Set<string>();
357
369
 
@@ -359,6 +371,7 @@ export class Engine {
359
371
  private static MAX_NUDGE_ROUNDS = 1;
360
372
  private static MAX_EMPTY_RESPONSE_ROUNDS = 1;
361
373
  private static MAX_STUCK_NUDGE_ROUNDS = 1;
374
+ private static MAX_RUNTIME_MS = 3 * 60 * 60 * 1000;
362
375
  private static OBSERVER_INTERVAL_MS = 5 * 60 * 1000;
363
376
  private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
364
377
  private static MAX_PROCESS_EXIT_NUDGE_ROUNDS = 1;
@@ -377,6 +390,25 @@ export class Engine {
377
390
  void this.recover();
378
391
  }
379
392
 
393
+ // An existing session must keep the backend that owns it: opencode session
394
+ // ids are "ses_..." while pi ids are bare uuids, so the prefix outvotes the
395
+ // per-issue override. New sessions follow the issue's runtime setting.
396
+ private backendFor(k: string, opencodeSessionId?: string): RuntimeBackend {
397
+ if (opencodeSessionId) {
398
+ const wants = opencodeSessionId.startsWith("ses_") ? "opencode" : "pi";
399
+ if (wants !== this.cfg.runtime) return this.altBackendFor(wants);
400
+ return this.backend;
401
+ }
402
+ const runtime = this.issueRuntimes.get(k);
403
+ if (!runtime || runtime === this.cfg.runtime) return this.backend;
404
+ return this.altBackendFor(runtime);
405
+ }
406
+
407
+ private altBackendFor(runtime: string): RuntimeBackend {
408
+ if (!this.altBackend) this.altBackend = createBackendFor(this.cfg, runtime);
409
+ return this.altBackend;
410
+ }
411
+
380
412
  private workdirLink(workdir: string): string {
381
413
  const p = encodeURIComponent(workdir);
382
414
  return `[${workdir}](/file?path=${p}&daemon_id=${this.daemonId})`;
@@ -557,6 +589,10 @@ export class Engine {
557
589
  return this.cfg.stuck?.maxNudges ?? Engine.MAX_STUCK_NUDGE_ROUNDS;
558
590
  }
559
591
 
592
+ private get maxRuntimeMs(): number {
593
+ return this.cfg.stuck?.maxRuntimeMs ?? Engine.MAX_RUNTIME_MS;
594
+ }
595
+
560
596
  private getTracker(type: string): IssueTracker {
561
597
  const tracker = this.trackers.get(type);
562
598
  if (!tracker) throw new Error(`Unknown tracker type: ${type}`);
@@ -738,6 +774,9 @@ export class Engine {
738
774
  if (event.cloneUrl) {
739
775
  this.cloneUrls.set(issueMapKey, event.cloneUrl);
740
776
  }
777
+ if (event.runtime === "pi" || event.runtime === "opencode") {
778
+ this.issueRuntimes.set(issueMapKey, event.runtime);
779
+ }
741
780
  if (event.sender) {
742
781
  this.senders.set(issueMapKey, event.sender);
743
782
  }
@@ -1198,13 +1237,14 @@ export class Engine {
1198
1237
  const fromStrategy = await this.takeover.resumeOpenCodeSession(session);
1199
1238
  if (fromStrategy) resumeSessionId = fromStrategy;
1200
1239
  }
1201
- if (resumeSessionId && !(await this.backend.sessionExists(resumeSessionId))) {
1240
+ if (resumeSessionId && !(await this.backendFor(k, resumeSessionId).sessionExists(resumeSessionId))) {
1202
1241
  log.warn(`stale session ${resumeSessionId} not found in db, starting fresh`);
1203
1242
  resumeSessionId = undefined;
1204
1243
  await this.store.updateSession(session.id, { opencodeSessionId: undefined });
1205
1244
  }
1206
1245
 
1207
- const model = msg.model || (this.cfg.runtime === "pi" && this.cfg.pi ? this.cfg.pi.defaultModel : this.cfg.opencode.defaultModel);
1246
+ const backend = this.backendFor(k, resumeSessionId);
1247
+ const model = msg.model || (backend instanceof PiBackend && this.cfg.pi ? this.cfg.pi.defaultModel : this.cfg.opencode.defaultModel);
1208
1248
  this.currentModel.set(k, model);
1209
1249
 
1210
1250
  if (msg.sourceCommentId) {
@@ -1216,7 +1256,7 @@ export class Engine {
1216
1256
  let exitCode: number | null = null;
1217
1257
 
1218
1258
  try {
1219
- const handle = await this.backend.spawn(
1259
+ const handle = await backend.spawn(
1220
1260
  {
1221
1261
  workdir,
1222
1262
  prompt: msg.content,
@@ -1264,7 +1304,7 @@ export class Engine {
1264
1304
  await this.store.updateSession(session.id, { opencodePid: handle.pid });
1265
1305
  await this.persistRuntimeState(session.id);
1266
1306
 
1267
- log.info(`engine: spawned pid=${handle.pid} for ${k} (backend=${this.backend.name})`);
1307
+ log.info(`engine: spawned pid=${handle.pid} for ${k} (backend=${backend.name})`);
1268
1308
 
1269
1309
  exitCode = await handle.exited;
1270
1310
  const stderr = await handle.stderrText;
@@ -1377,7 +1417,7 @@ export class Engine {
1377
1417
  });
1378
1418
  log.info(`engine: [bot] reply found for ${k} after prompt (comment ${matched?.id ?? "?"} createdAt ${matched?.createdAt ?? "?"}), marking done`);
1379
1419
  if (matched && !usedModel) {
1380
- const fromSession = await this.backend.lastSessionModel(session.opencodeSessionId).catch(() => ({ model: "" }));
1420
+ const fromSession = await this.backendFor(k, session.opencodeSessionId).lastSessionModel(session.opencodeSessionId).catch(() => ({ model: "" }));
1381
1421
  if (fromSession.model) {
1382
1422
  void tracker.setCommentModel(ref, matched.id, fromSession.model).catch(() => { /* display-only */ });
1383
1423
  }
@@ -1387,7 +1427,7 @@ export class Engine {
1387
1427
  await this.persistRuntimeState(session.id);
1388
1428
  void tracker.updateStatus(ref, "");
1389
1429
  } else {
1390
- const sessionOutput = await this.backend.getSessionOutputTokens(session.opencodeSessionId);
1430
+ const sessionOutput = await this.backendFor(k, session.opencodeSessionId).getSessionOutputTokens(session.opencodeSessionId);
1391
1431
  const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
1392
1432
 
1393
1433
  if (!sessionOutput.hasOutput && emptyRound < Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
@@ -1749,6 +1789,19 @@ export class Engine {
1749
1789
  continue;
1750
1790
  }
1751
1791
 
1792
+ // Process alive — cap total run time. Output-silence detection cannot
1793
+ // catch loops that keep emitting (observed: 6h of failing compress calls
1794
+ // every ~5s), so any single run is hard-stopped after maxRuntimeMs.
1795
+ const started = this.startedAt.get(k);
1796
+ if (started && Date.now() - started >= this.maxRuntimeMs) {
1797
+ const hrs = (this.maxRuntimeMs / 3600000).toFixed(1);
1798
+ log.warn(`engine: run exceeded max runtime (${hrs}h) on ${k}, stopping`);
1799
+ await tracker.createComment(this.sessionToRef(session, issue), `[system] ⏹ **${session.name}** run exceeded ${hrs}h — stopped. Reply again on the issue to continue.`).catch(() => { /* best-effort */ });
1800
+ this.nudgeRounds.set(k, Engine.MAX_NUDGE_ROUNDS);
1801
+ this.forceStop(k);
1802
+ continue;
1803
+ }
1804
+
1752
1805
  // Process alive — check stuck
1753
1806
  if (lastTs && Date.now() - lastTs >= this.stuckThresholdMs) {
1754
1807
  const minutes = Math.round((Date.now() - lastTs) / 60000);
@@ -138,6 +138,8 @@ export class GiteaTracker implements IssueTracker {
138
138
  // Empty/missing = no model override; engine omits --model.
139
139
  const modelRaw = repository.ework_model;
140
140
  const model = typeof modelRaw === "string" && modelRaw.trim() ? modelRaw.trim() : undefined;
141
+ const runtimeRaw = repository.ework_runtime;
142
+ const runtime = runtimeRaw === "pi" || runtimeRaw === "opencode" ? runtimeRaw : undefined;
141
143
 
142
144
  const ref: TrackerRef = {
143
145
  trackerType: "gitea",
@@ -162,6 +164,7 @@ export class GiteaTracker implements IssueTracker {
162
164
  ai_status: aiStatus,
163
165
  },
164
166
  model,
167
+ runtime,
165
168
  cloneUrl,
166
169
  sender,
167
170
  };
@@ -186,6 +189,7 @@ export class GiteaTracker implements IssueTracker {
186
189
  authorKind: (comment as Record<string, unknown>).author_kind as string | undefined,
187
190
  },
188
191
  model,
192
+ runtime,
189
193
  cloneUrl,
190
194
  sender,
191
195
  };
@@ -203,6 +207,7 @@ export class GiteaTracker implements IssueTracker {
203
207
  ai_status: aiStatus,
204
208
  },
205
209
  model,
210
+ runtime,
206
211
  cloneUrl,
207
212
  sender,
208
213
  };
@@ -36,6 +36,8 @@ export interface TrackerEvent {
36
36
  // global default). Empty/undefined = no override; engine omits --model
37
37
  // and lets opencode pick per its own opencode.json + env.
38
38
  model?: string;
39
+ // Runtime backend override for this issue from ework-web ("opencode"|"pi").
40
+ runtime?: string;
39
41
  // Real clone URL from the upstream tracker (e.g. Gitea repository.clone_url).
40
42
  // When present, RecloneStrategy uses this instead of the ework shim URL.
41
43
  cloneUrl?: string;