ework-daemon 0.4.27 โ 0.4.29
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 +21 -1
- package/src/index.ts +7 -0
- package/src/opencode.ts +58 -23
- package/src/runtime/pi-backend.ts +167 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -28,12 +28,20 @@ export const configSchema = z.object({
|
|
|
28
28
|
}),
|
|
29
29
|
opencode: z.object({
|
|
30
30
|
binary: z.string().default("opencode"),
|
|
31
|
-
baseWorkdir: z.string()
|
|
31
|
+
baseWorkdir: z.string().default(
|
|
32
|
+
`${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/ework-aio/opencode-workdir`
|
|
33
|
+
),
|
|
32
34
|
dbPath: z.string().default(
|
|
33
35
|
`${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`
|
|
34
36
|
),
|
|
35
37
|
defaultModel: z.string().default(""),
|
|
36
38
|
}),
|
|
39
|
+
pi: z.object({
|
|
40
|
+
binary: z.string().default("pi"),
|
|
41
|
+
provider: z.string().default("bailian"),
|
|
42
|
+
defaultModel: z.string().default(""),
|
|
43
|
+
}).optional(),
|
|
44
|
+
runtime: z.enum(["opencode", "pi"]).default("opencode"),
|
|
37
45
|
work: z.object({
|
|
38
46
|
capacity: z.coerce.number().int().positive().default(4),
|
|
39
47
|
maxConcurrent: z.coerce.number().int().positive().default(4),
|
|
@@ -138,6 +146,12 @@ export function loadConfig(): Config {
|
|
|
138
146
|
dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
|
|
139
147
|
defaultModel: process.env.WORK_DEFAULT_MODEL ?? TEST_DEFAULTS.opencode.defaultModel,
|
|
140
148
|
},
|
|
149
|
+
pi: {
|
|
150
|
+
binary: process.env.WORK_PI_BINARY ?? "pi",
|
|
151
|
+
provider: process.env.WORK_PI_PROVIDER ?? "bailian",
|
|
152
|
+
defaultModel: process.env.WORK_PI_DEFAULT_MODEL ?? process.env.WORK_DEFAULT_MODEL ?? "",
|
|
153
|
+
},
|
|
154
|
+
runtime: (process.env.WORK_RUNTIME ?? "opencode").trim().toLowerCase() as "opencode" | "pi",
|
|
141
155
|
work: readWorkSection(),
|
|
142
156
|
db: readDbSection(TEST_DEFAULTS.db.path),
|
|
143
157
|
completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
|
|
@@ -175,6 +189,12 @@ export function loadConfig(): Config {
|
|
|
175
189
|
dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
|
|
176
190
|
defaultModel: process.env.WORK_DEFAULT_MODEL ?? "",
|
|
177
191
|
},
|
|
192
|
+
pi: {
|
|
193
|
+
binary: process.env.WORK_PI_BINARY ?? "pi",
|
|
194
|
+
provider: process.env.WORK_PI_PROVIDER ?? "bailian",
|
|
195
|
+
defaultModel: process.env.WORK_PI_DEFAULT_MODEL ?? process.env.WORK_DEFAULT_MODEL ?? "",
|
|
196
|
+
},
|
|
197
|
+
runtime: (process.env.WORK_RUNTIME ?? "opencode").trim().toLowerCase() as "opencode" | "pi",
|
|
178
198
|
work: readWorkSection(),
|
|
179
199
|
db: readDbSection(PRODUCTION_DB_DEFAULT),
|
|
180
200
|
completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
|
package/src/index.ts
CHANGED
|
@@ -88,6 +88,13 @@ async function boot() {
|
|
|
88
88
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
89
89
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
90
90
|
|
|
91
|
+
process.on("unhandledRejection", (reason) => {
|
|
92
|
+
log.error("unhandledRejection (continuing):", reason);
|
|
93
|
+
});
|
|
94
|
+
process.on("uncaughtException", (err) => {
|
|
95
|
+
log.error("uncaughtException (continuing):", err);
|
|
96
|
+
});
|
|
97
|
+
|
|
91
98
|
const activeCount = (await store.listActiveIssues()).length;
|
|
92
99
|
log.info(`\n${isTest ? "๐งช" : "โ
"} ework-daemon ready at http://${server.hostname}:${server.port}/webhook`);
|
|
93
100
|
log.info(` Configure Gitea webhook to POST to /webhook/gitea`);
|
package/src/opencode.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { IssueTracker, TrackerRef, TrackerEvent, TrackerComment, Issue, OpS
|
|
|
9
9
|
import { formatKey, parseKey } from "./trackers/types";
|
|
10
10
|
import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
|
|
11
11
|
import { OpencodeBackend } from "./runtime/opencode-backend";
|
|
12
|
+
import { PiBackend } from "./runtime/pi-backend";
|
|
12
13
|
|
|
13
14
|
// โโโ Types โโโ
|
|
14
15
|
|
|
@@ -279,14 +280,18 @@ export function pickLastActive(sessions: OpSession[]): OpSession | undefined {
|
|
|
279
280
|
// โโโ Engine โโโ
|
|
280
281
|
|
|
281
282
|
export interface EngineOptions {
|
|
282
|
-
/** DB-allocated logical daemon id (from Store.registerDaemon). */
|
|
283
283
|
daemonId: number;
|
|
284
|
-
/** Workdir + session-resume strategy; defaults to RecloneStrategy. */
|
|
285
284
|
takeover?: TakeoverStrategy;
|
|
286
|
-
/** Runtime backend (opencode/pi); defaults to OpencodeBackend. */
|
|
287
285
|
backend?: RuntimeBackend;
|
|
288
286
|
}
|
|
289
287
|
|
|
288
|
+
function createDefaultBackend(cfg: Config): RuntimeBackend {
|
|
289
|
+
if (cfg.runtime === "pi" && cfg.pi) {
|
|
290
|
+
return new PiBackend(cfg.pi.binary, cfg.pi.provider, cfg.pi.defaultModel, cfg.childEnvDeny);
|
|
291
|
+
}
|
|
292
|
+
return new OpencodeBackend(cfg.opencode.binary, cfg.opencode.dbPath, cfg.childEnvDeny);
|
|
293
|
+
}
|
|
294
|
+
|
|
290
295
|
export class Engine {
|
|
291
296
|
private cfg: Config;
|
|
292
297
|
private store: Store;
|
|
@@ -342,7 +347,7 @@ export class Engine {
|
|
|
342
347
|
this.trackers = trackers;
|
|
343
348
|
this.daemonId = opts.daemonId;
|
|
344
349
|
this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
|
|
345
|
-
this.backend = opts.backend ??
|
|
350
|
+
this.backend = opts.backend ?? createDefaultBackend(cfg);
|
|
346
351
|
this.startGlobalObserver();
|
|
347
352
|
void this.recover();
|
|
348
353
|
}
|
|
@@ -557,8 +562,8 @@ export class Engine {
|
|
|
557
562
|
const tracker = this.getTracker(ref.trackerType);
|
|
558
563
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
559
564
|
|
|
560
|
-
if (this.paused && event.type === "issue_opened") {
|
|
561
|
-
log.info(`engine: paused โ skipping
|
|
565
|
+
if (this.paused && (event.type === "issue_opened" || event.type === "comment_created")) {
|
|
566
|
+
log.info(`engine: paused โ skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
562
567
|
return;
|
|
563
568
|
}
|
|
564
569
|
|
|
@@ -791,13 +796,11 @@ export class Engine {
|
|
|
791
796
|
|
|
792
797
|
// Kill all running processes for this issue's sessions
|
|
793
798
|
const sessions = await this.store.getSessionsForIssue(issue.id);
|
|
799
|
+
let killedCount = 0;
|
|
794
800
|
for (const session of sessions) {
|
|
795
801
|
const k = this.sessionKey(session, issue);
|
|
796
|
-
const
|
|
797
|
-
if (
|
|
798
|
-
this.stopping.add(k);
|
|
799
|
-
try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
800
|
-
}
|
|
802
|
+
const killed = await this.killSessionProcess(session, k);
|
|
803
|
+
if (killed) killedCount++;
|
|
801
804
|
this.clearRuntimeState(k);
|
|
802
805
|
const msgs = await this.store.getMessagesForSession(session.id);
|
|
803
806
|
for (const msg of msgs) {
|
|
@@ -824,7 +827,7 @@ export class Engine {
|
|
|
824
827
|
this.cloneUrls.delete(gcKey);
|
|
825
828
|
this.senders.delete(gcKey);
|
|
826
829
|
|
|
827
|
-
log.info(`engine: issue closed, ${sessions.length} sessions
|
|
830
|
+
log.info(`engine: issue closed, ${killedCount}/${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
|
|
828
831
|
void tracker.updateStatus(ref, "completed");
|
|
829
832
|
}
|
|
830
833
|
|
|
@@ -837,13 +840,11 @@ export class Engine {
|
|
|
837
840
|
if (!issue) return;
|
|
838
841
|
this.stopObserver(issue.id);
|
|
839
842
|
const sessions = await this.store.getSessionsForIssue(issue.id);
|
|
843
|
+
let killedCount = 0;
|
|
840
844
|
for (const session of sessions) {
|
|
841
845
|
const k = this.sessionKey(session, issue);
|
|
842
|
-
const
|
|
843
|
-
if (
|
|
844
|
-
this.stopping.add(k);
|
|
845
|
-
try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
846
|
-
}
|
|
846
|
+
const killed = await this.killSessionProcess(session, k);
|
|
847
|
+
if (killed) killedCount++;
|
|
847
848
|
this.clearRuntimeState(k);
|
|
848
849
|
const msgs = await this.store.getMessagesForSession(session.id);
|
|
849
850
|
for (const msg of msgs) {
|
|
@@ -853,7 +854,7 @@ export class Engine {
|
|
|
853
854
|
}
|
|
854
855
|
await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
|
|
855
856
|
}
|
|
856
|
-
log.info(`engine: issue halted, ${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
|
|
857
|
+
log.info(`engine: issue halted, ${killedCount}/${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
|
|
857
858
|
try { await tracker.createComment(ref, "[system] โธ๏ธ AI processing halted by user."); } catch { /* tracker unavailable */ }
|
|
858
859
|
}
|
|
859
860
|
|
|
@@ -873,6 +874,36 @@ export class Engine {
|
|
|
873
874
|
this.generation.delete(k);
|
|
874
875
|
}
|
|
875
876
|
|
|
877
|
+
private async killSessionProcess(session: OpSession, k: string): Promise<boolean> {
|
|
878
|
+
const handle = this.processes.get(k);
|
|
879
|
+
if (handle) {
|
|
880
|
+
this.stopping.add(k);
|
|
881
|
+
try { this.killProcessTree(handle.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
882
|
+
this.processes.delete(k);
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const pid = session.opencodePid;
|
|
887
|
+
if (!pid) return false;
|
|
888
|
+
try {
|
|
889
|
+
process.kill(pid, 0);
|
|
890
|
+
} catch {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
log.info(`engine: killing orphaned pid=${pid} for ${k} (cross-restart)`);
|
|
895
|
+
this.stopping.add(k);
|
|
896
|
+
try {
|
|
897
|
+
this.killProcessTree(pid, "SIGTERM");
|
|
898
|
+
for (let i = 0; i < 30; i++) {
|
|
899
|
+
await new Promise(r => setTimeout(r, 100));
|
|
900
|
+
try { process.kill(pid, 0); } catch { break; }
|
|
901
|
+
}
|
|
902
|
+
try { process.kill(pid, "SIGKILL"); } catch { /* dead */ }
|
|
903
|
+
} catch { /* already dead */ }
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
|
|
876
907
|
// โโโ Preemptive Scheduler โโโ
|
|
877
908
|
|
|
878
909
|
private async enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string, model?: string) {
|
|
@@ -978,7 +1009,7 @@ export class Engine {
|
|
|
978
1009
|
await this.store.updateSession(session.id, { opencodeSessionId: undefined });
|
|
979
1010
|
}
|
|
980
1011
|
|
|
981
|
-
const model = msg.model || this.cfg.opencode.defaultModel;
|
|
1012
|
+
const model = msg.model || (this.cfg.runtime === "pi" && this.cfg.pi ? this.cfg.pi.defaultModel : this.cfg.opencode.defaultModel);
|
|
982
1013
|
this.currentModel.set(k, model);
|
|
983
1014
|
|
|
984
1015
|
if (msg.sourceCommentId) {
|
|
@@ -1388,16 +1419,20 @@ export class Engine {
|
|
|
1388
1419
|
}
|
|
1389
1420
|
|
|
1390
1421
|
private async runObserverCycle() {
|
|
1391
|
-
// Multi-machine: periodically release stale owners so we can adopt their
|
|
1392
|
-
// work, and only iterate issues/sessions this daemon owns.
|
|
1393
1422
|
try {
|
|
1394
1423
|
await this.store.releaseDeadOwners(this.cfg.work.leaseTtlMs);
|
|
1395
1424
|
} catch (err) {
|
|
1396
1425
|
log.error("engine: releaseDeadOwners failed:", (err as Error).message);
|
|
1397
1426
|
}
|
|
1398
1427
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1428
|
+
let ownedIssues;
|
|
1429
|
+
try {
|
|
1430
|
+
ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
|
|
1431
|
+
.filter((i) => this.observedIssues.has(i.id));
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
log.error("engine: listOwnedIssues failed:", (err as Error).message);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1401
1436
|
|
|
1402
1437
|
for (const issue of ownedIssues) {
|
|
1403
1438
|
try {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { readdirSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { log } from "../logger";
|
|
5
|
+
import type {
|
|
6
|
+
RuntimeBackend,
|
|
7
|
+
RuntimeSpawnOpts,
|
|
8
|
+
RuntimeSpawnCallbacks,
|
|
9
|
+
RuntimeHandle,
|
|
10
|
+
SessionOutputResult,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
export class PiBackend implements RuntimeBackend {
|
|
14
|
+
readonly name = "pi";
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private binary: string,
|
|
18
|
+
private provider: string,
|
|
19
|
+
private defaultModel: string | undefined,
|
|
20
|
+
private childEnvDeny: string[] = [],
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async spawn(opts: RuntimeSpawnOpts, cb: RuntimeSpawnCallbacks): Promise<RuntimeHandle> {
|
|
24
|
+
const args: string[] = [this.binary, "--mode", "json", "--print"];
|
|
25
|
+
|
|
26
|
+
const model = opts.model || this.defaultModel;
|
|
27
|
+
if (model) {
|
|
28
|
+
if (model.includes("/")) {
|
|
29
|
+
const slashIdx = model.indexOf("/");
|
|
30
|
+
const provider = model.slice(0, slashIdx);
|
|
31
|
+
const modelId = model.slice(slashIdx + 1);
|
|
32
|
+
args.push("--provider", provider, "--model", modelId);
|
|
33
|
+
} else {
|
|
34
|
+
args.push("--provider", this.provider, "--model", model);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
args.push("--provider", this.provider);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (opts.resumeSessionId) {
|
|
41
|
+
args.push("--session-id", opts.resumeSessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
args.push(opts.prompt);
|
|
45
|
+
|
|
46
|
+
const childEnv = { ...opts.env };
|
|
47
|
+
for (const key of this.childEnvDeny) delete childEnv[key];
|
|
48
|
+
|
|
49
|
+
const proc = spawn({
|
|
50
|
+
cmd: args,
|
|
51
|
+
cwd: opts.workdir,
|
|
52
|
+
env: childEnv,
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "pipe",
|
|
55
|
+
stdin: "ignore",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const stderrText = new Response(proc.stderr).text();
|
|
59
|
+
|
|
60
|
+
void this.readStdout(proc, cb);
|
|
61
|
+
|
|
62
|
+
return { pid: proc.pid, exited: proc.exited, stderrText };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async readStdout(
|
|
66
|
+
proc: ReturnType<typeof spawn>,
|
|
67
|
+
cb: RuntimeSpawnCallbacks,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
let captured = false;
|
|
70
|
+
const stdout = proc.stdout;
|
|
71
|
+
if (!stdout || typeof stdout === "number") return;
|
|
72
|
+
const reader = stdout.getReader();
|
|
73
|
+
const decoder = new TextDecoder();
|
|
74
|
+
let lineBuf = "";
|
|
75
|
+
|
|
76
|
+
while (true) {
|
|
77
|
+
const { done, value } = await reader.read();
|
|
78
|
+
if (done) break;
|
|
79
|
+
|
|
80
|
+
cb.onOutput();
|
|
81
|
+
|
|
82
|
+
if (!captured) {
|
|
83
|
+
lineBuf += decoder.decode(value, { stream: true });
|
|
84
|
+
const lines = lineBuf.split("\n");
|
|
85
|
+
lineBuf = lines.pop()!;
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
try {
|
|
89
|
+
const ev = JSON.parse(line);
|
|
90
|
+
if (ev.type === "session" && ev.id) {
|
|
91
|
+
await cb.onSessionId(ev.id as string);
|
|
92
|
+
captured = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
} catch { /* not json */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async sessionExists(sessionId: string): Promise<boolean> {
|
|
102
|
+
const sessionDir = this.resolveSessionDir();
|
|
103
|
+
if (!existsSync(sessionDir)) return false;
|
|
104
|
+
try {
|
|
105
|
+
const files = readdirSync(sessionDir);
|
|
106
|
+
return files.some((f) => f.includes(sessionId) && f.endsWith(".jsonl"));
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult> {
|
|
113
|
+
if (!sessionId) return { hasOutput: true, tokenCount: 0 };
|
|
114
|
+
|
|
115
|
+
const filePath = this.findSessionFile(sessionId);
|
|
116
|
+
if (!filePath) return { hasOutput: true, tokenCount: 0 };
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const { readFileSync } = await import("fs");
|
|
120
|
+
const content = readFileSync(filePath, "utf-8");
|
|
121
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
122
|
+
let totalTokens = 0;
|
|
123
|
+
let hasAssistant = false;
|
|
124
|
+
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
try {
|
|
127
|
+
const ev = JSON.parse(line);
|
|
128
|
+
if (ev.type === "message" && ev.message?.role === "assistant") {
|
|
129
|
+
hasAssistant = true;
|
|
130
|
+
const output = ev.message?.usage?.output ?? 0;
|
|
131
|
+
totalTokens += output;
|
|
132
|
+
}
|
|
133
|
+
} catch { /* skip */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { hasOutput: hasAssistant && totalTokens > 0, tokenCount: totalTokens };
|
|
137
|
+
} catch {
|
|
138
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private resolveSessionDir(): string {
|
|
143
|
+
const envDir = process.env.PI_CODING_AGENT_SESSION_DIR;
|
|
144
|
+
if (envDir) return envDir;
|
|
145
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent");
|
|
146
|
+
return join(agentDir, "sessions");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private findSessionFile(sessionId: string): string | null {
|
|
150
|
+
const sessionDir = this.resolveSessionDir();
|
|
151
|
+
if (!existsSync(sessionDir)) return null;
|
|
152
|
+
try {
|
|
153
|
+
const subdirs = readdirSync(sessionDir, { withFileTypes: true });
|
|
154
|
+
for (const dir of subdirs) {
|
|
155
|
+
if (!dir.isDirectory()) continue;
|
|
156
|
+
const dirPath = join(sessionDir, dir.name);
|
|
157
|
+
const files = readdirSync(dirPath);
|
|
158
|
+
for (const f of files) {
|
|
159
|
+
if (f.includes(sessionId) && f.endsWith(".jsonl")) {
|
|
160
|
+
return join(dirPath, f);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch { /* not found */ }
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|