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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
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
@@ -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
 
@@ -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