ework-daemon 0.4.21 → 0.4.23
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 +3 -0
- package/src/gitea.ts +17 -0
- package/src/opencode.ts +60 -3
- package/src/server.ts +9 -0
- package/src/trackers/gitea-tracker.ts +26 -0
- package/src/trackers/types.ts +3 -1
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -64,6 +64,7 @@ export const configSchema = z.object({
|
|
|
64
64
|
maxLines: z.coerce.number().int().positive().default(2000),
|
|
65
65
|
maxBytes: z.coerce.number().int().positive().default(524288),
|
|
66
66
|
}).default({ roots: [], maxLines: 2000, maxBytes: 524288 }),
|
|
67
|
+
childEnvDeny: z.array(z.string()).default([]),
|
|
67
68
|
});
|
|
68
69
|
|
|
69
70
|
export type Config = z.infer<typeof configSchema>;
|
|
@@ -145,6 +146,7 @@ export function loadConfig(): Config {
|
|
|
145
146
|
thresholdMs: Number(process.env.DAEMON_STUCK_THRESHOLD_MS) || 30 * 60 * 1000,
|
|
146
147
|
maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
|
|
147
148
|
} : undefined,
|
|
149
|
+
childEnvDeny: (process.env.WORK_CHILD_ENV_DENY ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
148
150
|
});
|
|
149
151
|
}
|
|
150
152
|
|
|
@@ -188,5 +190,6 @@ export function loadConfig(): Config {
|
|
|
188
190
|
maxLines: Number(process.env.WORK_FILE_MAX_LINES) || 2000,
|
|
189
191
|
maxBytes: Number(process.env.WORK_FILE_MAX_BYTES) || 524288,
|
|
190
192
|
},
|
|
193
|
+
childEnvDeny: (process.env.WORK_CHILD_ENV_DENY ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
191
194
|
});
|
|
192
195
|
}
|
package/src/gitea.ts
CHANGED
|
@@ -63,6 +63,23 @@ export class GiteaClient {
|
|
|
63
63
|
}, true);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
async updateIssueStatus(
|
|
67
|
+
owner: string,
|
|
68
|
+
repo: string,
|
|
69
|
+
issueNumber: number,
|
|
70
|
+
status: string,
|
|
71
|
+
detail?: string
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
await this.request("POST", `/repos/${owner}/${repo}/issues/${issueNumber}/status`, {
|
|
75
|
+
status,
|
|
76
|
+
detail,
|
|
77
|
+
}, true);
|
|
78
|
+
} catch {
|
|
79
|
+
// Status callback is best-effort — don't block processing on API failure.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
async editComment(
|
|
67
84
|
owner: string,
|
|
68
85
|
repo: string,
|
package/src/opencode.ts
CHANGED
|
@@ -308,6 +308,7 @@ export class Engine {
|
|
|
308
308
|
private processExitNudgeRounds = new Map<string, number>();
|
|
309
309
|
private stuckNudgeRounds = new Map<string, number>();
|
|
310
310
|
private currentPrompt = new Map<string, string>();
|
|
311
|
+
private paused = false;
|
|
311
312
|
|
|
312
313
|
// Generation counter per session key — incremented on every execProcess call.
|
|
313
314
|
// finishRun captures the generation at start and checks it after each await.
|
|
@@ -372,6 +373,22 @@ export class Engine {
|
|
|
372
373
|
return this.daemonId;
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
async pause(): Promise<void> {
|
|
377
|
+
this.paused = true;
|
|
378
|
+
await this.store.markDaemonStatus(this.daemonId, "drained");
|
|
379
|
+
log.info(`engine: daemon ${this.daemonId} paused (drained) — new issues rejected, existing sessions continue`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async resume(): Promise<void> {
|
|
383
|
+
this.paused = false;
|
|
384
|
+
await this.store.markDaemonStatus(this.daemonId, "active");
|
|
385
|
+
log.info(`engine: daemon ${this.daemonId} resumed (active)`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
isPaused(): boolean {
|
|
389
|
+
return this.paused;
|
|
390
|
+
}
|
|
391
|
+
|
|
375
392
|
/**
|
|
376
393
|
* Ensure this engine owns the issue before doing work on it. Returns true
|
|
377
394
|
* if we own it (either already, or just claimed). Returns false if another
|
|
@@ -539,6 +556,11 @@ export class Engine {
|
|
|
539
556
|
const tracker = this.getTracker(ref.trackerType);
|
|
540
557
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
541
558
|
|
|
559
|
+
if (this.paused && event.type === "issue_opened") {
|
|
560
|
+
log.info(`engine: paused — skipping issue_opened for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
542
564
|
const issueMapKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
543
565
|
if (groupConfig) {
|
|
544
566
|
this.groupConfigs.set(issueMapKey, groupConfig);
|
|
@@ -557,6 +579,11 @@ export class Engine {
|
|
|
557
579
|
return this.handleCommented(ref, scopeKey, issueData, event.comment!, tracker, event.model);
|
|
558
580
|
case "issue_closed":
|
|
559
581
|
return this.handleClosed(ref, scopeKey, tracker);
|
|
582
|
+
case "status_changed": {
|
|
583
|
+
const to = event.status?.to;
|
|
584
|
+
if (to === "halted") return this.handleHalted(ref, scopeKey, tracker);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
560
587
|
}
|
|
561
588
|
}
|
|
562
589
|
|
|
@@ -607,6 +634,7 @@ export class Engine {
|
|
|
607
634
|
log.info(`engine: session "${session.name}" created for ${k}, workdir=${workdir}`);
|
|
608
635
|
|
|
609
636
|
await tracker.createComment(ref, `[system] 🔄 **${session.name}** picked up this issue.\n> session: ${this.sessionRef(session)} | workdir: ${this.workdirLink(workdir)}`);
|
|
637
|
+
void tracker.updateStatus(ref, "processing");
|
|
610
638
|
|
|
611
639
|
const instructions = tracker.getTrackerInstructions(ref);
|
|
612
640
|
const prompt = this.buildInitialPrompt(
|
|
@@ -791,6 +819,36 @@ export class Engine {
|
|
|
791
819
|
this.senders.delete(gcKey);
|
|
792
820
|
|
|
793
821
|
log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
|
|
822
|
+
void tracker.updateStatus(ref, "completed");
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private async handleHalted(
|
|
826
|
+
ref: TrackerRef,
|
|
827
|
+
scopeKey: string,
|
|
828
|
+
tracker: IssueTracker
|
|
829
|
+
) {
|
|
830
|
+
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
831
|
+
if (!issue) return;
|
|
832
|
+
this.stopObserver(issue.id);
|
|
833
|
+
const sessions = await this.store.getSessionsForIssue(issue.id);
|
|
834
|
+
for (const session of sessions) {
|
|
835
|
+
const k = this.sessionKey(session, issue);
|
|
836
|
+
const proc = this.processes.get(k);
|
|
837
|
+
if (proc) {
|
|
838
|
+
this.stopping.add(k);
|
|
839
|
+
try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
840
|
+
}
|
|
841
|
+
this.clearRuntimeState(k);
|
|
842
|
+
const msgs = await this.store.getMessagesForSession(session.id);
|
|
843
|
+
for (const msg of msgs) {
|
|
844
|
+
if (msg.status === "pending" || msg.status === "running") {
|
|
845
|
+
await this.store.updateMessageStatus(msg.id, "interrupted", "halted by user");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
|
|
849
|
+
}
|
|
850
|
+
log.info(`engine: issue halted, ${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
|
|
851
|
+
try { await tracker.createComment(ref, "[system] ⏸️ AI processing halted by user."); } catch { /* tracker unavailable */ }
|
|
794
852
|
}
|
|
795
853
|
|
|
796
854
|
private clearRuntimeState(k: string) {
|
|
@@ -941,14 +999,13 @@ export class Engine {
|
|
|
941
999
|
// (the operator's explicit config) instead of arbitrary env pollution.
|
|
942
1000
|
// Provider keys / Gitea vars are preserved (opencode + its plugin need
|
|
943
1001
|
// them); only the explicit model-override var is neutralized.
|
|
944
|
-
const childEnv = { ...process.env };
|
|
1002
|
+
const childEnv = { ...process.env, ...this.hookEnvFor(issue, session, workdir) };
|
|
945
1003
|
if (!model) delete childEnv.OPENCODE_MODEL;
|
|
946
|
-
// Strip parent-session env vars so the child opencode reads its own
|
|
947
|
-
// opencode.json instead of inheriting a parent session's model/provider.
|
|
948
1004
|
delete childEnv.OPENCODE;
|
|
949
1005
|
delete childEnv.OPENCODE_PID;
|
|
950
1006
|
delete childEnv.OPENCODE_RUN_ID;
|
|
951
1007
|
delete childEnv.OPENCODE_PROCESS_ROLE;
|
|
1008
|
+
for (const k of this.cfg.childEnvDeny) delete childEnv[k];
|
|
952
1009
|
|
|
953
1010
|
try {
|
|
954
1011
|
const proc = spawn({
|
package/src/server.ts
CHANGED
|
@@ -205,6 +205,15 @@ export function createServer(
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
if (pathname === "/api/admin/pause" && req.method === "POST") {
|
|
209
|
+
await engine.pause();
|
|
210
|
+
return json({ ok: true, paused: true });
|
|
211
|
+
}
|
|
212
|
+
if (pathname === "/api/admin/resume" && req.method === "POST") {
|
|
213
|
+
await engine.resume();
|
|
214
|
+
return json({ ok: true, paused: false });
|
|
215
|
+
}
|
|
216
|
+
|
|
208
217
|
return json({ error: "not found" }, 404);
|
|
209
218
|
}
|
|
210
219
|
|
|
@@ -65,6 +65,12 @@ export class GiteaTracker implements IssueTracker {
|
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
async updateStatus(ref: TrackerRef, status: string, detail?: string) {
|
|
69
|
+
await this.client.updateIssueStatus(
|
|
70
|
+
this.owner(ref), this.repo(ref), Number(ref.issueId), status, detail
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
async setReaction(ref: TrackerRef, commentId: string, content: string, remove = false) {
|
|
69
75
|
if (remove) {
|
|
70
76
|
await this.client.removeCommentReaction(
|
|
@@ -189,6 +195,26 @@ export class GiteaTracker implements IssueTracker {
|
|
|
189
195
|
};
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
if (action === "status_changed") {
|
|
199
|
+
const status = payload.status as { from?: string; to?: string; detail?: string } | undefined;
|
|
200
|
+
return {
|
|
201
|
+
type: "status_changed" as const,
|
|
202
|
+
ref,
|
|
203
|
+
issue: {
|
|
204
|
+
title: issue.title as string,
|
|
205
|
+
body: (issue.body as string) ?? "",
|
|
206
|
+
state: (issue.state as string) ?? "open",
|
|
207
|
+
author: issueUser?.login ?? "",
|
|
208
|
+
},
|
|
209
|
+
status: {
|
|
210
|
+
from: status?.from ?? "",
|
|
211
|
+
to: status?.to ?? "",
|
|
212
|
+
detail: status?.detail,
|
|
213
|
+
},
|
|
214
|
+
sender,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
return null;
|
|
193
219
|
}
|
|
194
220
|
|
package/src/trackers/types.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface TrackerRef {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** Parsed, tracker-agnostic webhook event */
|
|
16
|
-
export type TrackerEventType = "issue_opened" | "comment_created" | "issue_closed";
|
|
16
|
+
export type TrackerEventType = "issue_opened" | "comment_created" | "issue_closed" | "status_changed";
|
|
17
17
|
|
|
18
18
|
export interface TrackerEvent {
|
|
19
19
|
type: TrackerEventType;
|
|
@@ -39,6 +39,7 @@ export interface TrackerEvent {
|
|
|
39
39
|
// Webhook sender (who triggered the event). Forwarded to the daemon's
|
|
40
40
|
// task context so the AI knows who it's responding to.
|
|
41
41
|
sender?: string;
|
|
42
|
+
status?: { from: string; to: string; detail?: string };
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** Tracker-agnostic comment */
|
|
@@ -130,6 +131,7 @@ export interface IssueTracker {
|
|
|
130
131
|
deleteComment(ref: TrackerRef, commentId: string): Promise<void>;
|
|
131
132
|
listComments(ref: TrackerRef): Promise<TrackerComment[]>;
|
|
132
133
|
closeIssue(ref: TrackerRef): Promise<void>;
|
|
134
|
+
updateStatus(ref: TrackerRef, status: string, detail?: string): Promise<void>;
|
|
133
135
|
|
|
134
136
|
setReaction(ref: TrackerRef, commentId: string, content: string, remove?: boolean): Promise<void>;
|
|
135
137
|
|