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 +1 -1
- package/src/config.ts +5 -2
- package/src/opencode.ts +59 -6
- package/src/trackers/gitea-tracker.ts +5 -0
- package/src/trackers/types.ts +2 -0
package/package.json
CHANGED
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.
|
|
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
|
|
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
|
|
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=${
|
|
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.
|
|
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.
|
|
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
|
};
|
package/src/trackers/types.ts
CHANGED
|
@@ -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;
|