ework-daemon 0.4.22 → 0.4.24

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.22",
3
+ "version": "0.4.24",
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/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 (e) {
79
+ console.warn("[gitea] updateIssueStatus failed:", (e as Error).message);
80
+ }
81
+ }
82
+
66
83
  async editComment(
67
84
  owner: string,
68
85
  repo: string,
package/src/opencode.ts CHANGED
@@ -561,6 +561,11 @@ export class Engine {
561
561
  return;
562
562
  }
563
563
 
564
+ if (issueData.ai_status === "halted" && (event.type === "issue_opened" || event.type === "comment_created")) {
565
+ log.info(`engine: issue halted — skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
566
+ return;
567
+ }
568
+
564
569
  const issueMapKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
565
570
  if (groupConfig) {
566
571
  this.groupConfigs.set(issueMapKey, groupConfig);
@@ -579,6 +584,11 @@ export class Engine {
579
584
  return this.handleCommented(ref, scopeKey, issueData, event.comment!, tracker, event.model);
580
585
  case "issue_closed":
581
586
  return this.handleClosed(ref, scopeKey, tracker);
587
+ case "status_changed": {
588
+ const to = event.status?.to;
589
+ if (to === "halted") return this.handleHalted(ref, scopeKey, tracker);
590
+ return;
591
+ }
582
592
  }
583
593
  }
584
594
 
@@ -629,6 +639,7 @@ export class Engine {
629
639
  log.info(`engine: session "${session.name}" created for ${k}, workdir=${workdir}`);
630
640
 
631
641
  await tracker.createComment(ref, `[system] 🔄 **${session.name}** picked up this issue.\n> session: ${this.sessionRef(session)} | workdir: ${this.workdirLink(workdir)}`);
642
+ void tracker.updateStatus(ref, "processing");
632
643
 
633
644
  const instructions = tracker.getTrackerInstructions(ref);
634
645
  const prompt = this.buildInitialPrompt(
@@ -813,6 +824,36 @@ export class Engine {
813
824
  this.senders.delete(gcKey);
814
825
 
815
826
  log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
827
+ void tracker.updateStatus(ref, "completed");
828
+ }
829
+
830
+ private async handleHalted(
831
+ ref: TrackerRef,
832
+ scopeKey: string,
833
+ tracker: IssueTracker
834
+ ) {
835
+ const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
836
+ if (!issue) return;
837
+ this.stopObserver(issue.id);
838
+ const sessions = await this.store.getSessionsForIssue(issue.id);
839
+ for (const session of sessions) {
840
+ const k = this.sessionKey(session, issue);
841
+ const proc = this.processes.get(k);
842
+ if (proc) {
843
+ this.stopping.add(k);
844
+ try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
845
+ }
846
+ this.clearRuntimeState(k);
847
+ const msgs = await this.store.getMessagesForSession(session.id);
848
+ for (const msg of msgs) {
849
+ if (msg.status === "pending" || msg.status === "running") {
850
+ await this.store.updateMessageStatus(msg.id, "interrupted", "halted by user");
851
+ }
852
+ }
853
+ await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
854
+ }
855
+ log.info(`engine: issue halted, ${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
856
+ try { await tracker.createComment(ref, "[system] ⏸️ AI processing halted by user."); } catch { /* tracker unavailable */ }
816
857
  }
817
858
 
818
859
  private clearRuntimeState(k: string) {
@@ -1127,6 +1168,7 @@ export class Engine {
1127
1168
  if (exitCode === null) {
1128
1169
  log.info(`engine: spawn failed for ${k}, skipping completion check`);
1129
1170
  await this.store.updateSession(session.id, { opencodePid: undefined });
1171
+ void tracker.updateStatus(ref, "failed", "spawn failed");
1130
1172
  await this.deactivateIfIdle(k, session, issue);
1131
1173
  return;
1132
1174
  }
@@ -1149,6 +1191,7 @@ export class Engine {
1149
1191
  this.nudgeRounds.delete(k);
1150
1192
  this.emptyResponseRounds.delete(k);
1151
1193
  await this.persistRuntimeState(session.id);
1194
+ void tracker.updateStatus(ref, "");
1152
1195
  } else {
1153
1196
  const sessionOutput = await this.checkSessionOutput(session.opencodeSessionId);
1154
1197
  const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
@@ -1171,6 +1214,7 @@ export class Engine {
1171
1214
  this.emptyResponseRounds.delete(k);
1172
1215
  this.nudgeRounds.delete(k);
1173
1216
  await tracker.createComment(ref, `[system] ❌ **${session.name}** 模型返回空响应(0 token),已重试 ${emptyRound} 次。请检查模型配置或稍后重试。`).catch(() => {});
1217
+ void tracker.updateStatus(ref, "failed", "empty model response");
1174
1218
  } else {
1175
1219
  const nudgeRound = this.nudgeRounds.get(k) ?? 0;
1176
1220
  if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
@@ -1189,6 +1233,7 @@ export class Engine {
1189
1233
  this.nudgeRounds.delete(k);
1190
1234
  const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
1191
1235
  await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
1236
+ void tracker.updateStatus(ref, "failed", detail);
1192
1237
  }
1193
1238
  }
1194
1239
 
@@ -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(
@@ -134,6 +140,7 @@ export class GiteaTracker implements IssueTracker {
134
140
  };
135
141
 
136
142
  const issueUser = issue.user as Record<string, string>;
143
+ const aiStatus = typeof issue.ai_status === "string" ? issue.ai_status : "";
137
144
 
138
145
  if (action === "opened" || action === "reopened") {
139
146
  return {
@@ -144,6 +151,7 @@ export class GiteaTracker implements IssueTracker {
144
151
  body: (issue.body as string) ?? "",
145
152
  state: (issue.state as string) ?? "open",
146
153
  author: issueUser?.login ?? "",
154
+ ai_status: aiStatus,
147
155
  },
148
156
  model,
149
157
  cloneUrl,
@@ -161,6 +169,7 @@ export class GiteaTracker implements IssueTracker {
161
169
  body: (issue.body as string) ?? "",
162
170
  state: (issue.state as string) ?? "open",
163
171
  author: issueUser?.login ?? "",
172
+ ai_status: aiStatus,
164
173
  },
165
174
  comment: {
166
175
  id: String(comment.id),
@@ -182,6 +191,7 @@ export class GiteaTracker implements IssueTracker {
182
191
  body: (issue.body as string) ?? "",
183
192
  state: "closed",
184
193
  author: issueUser?.login ?? "",
194
+ ai_status: aiStatus,
185
195
  },
186
196
  model,
187
197
  cloneUrl,
@@ -189,6 +199,27 @@ export class GiteaTracker implements IssueTracker {
189
199
  };
190
200
  }
191
201
 
202
+ if (action === "status_changed") {
203
+ const status = payload.status as { from?: string; to?: string; detail?: string } | undefined;
204
+ return {
205
+ type: "status_changed" as const,
206
+ ref,
207
+ issue: {
208
+ title: issue.title as string,
209
+ body: (issue.body as string) ?? "",
210
+ state: (issue.state as string) ?? "open",
211
+ author: issueUser?.login ?? "",
212
+ ai_status: aiStatus,
213
+ },
214
+ status: {
215
+ from: status?.from ?? "",
216
+ to: status?.to ?? "",
217
+ detail: status?.detail,
218
+ },
219
+ sender,
220
+ };
221
+ }
222
+
192
223
  return null;
193
224
  }
194
225
 
@@ -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;
@@ -23,6 +23,7 @@ export interface TrackerEvent {
23
23
  body: string;
24
24
  state: string;
25
25
  author: string;
26
+ ai_status?: string;
26
27
  };
27
28
  comment?: {
28
29
  id: string;
@@ -39,6 +40,7 @@ export interface TrackerEvent {
39
40
  // Webhook sender (who triggered the event). Forwarded to the daemon's
40
41
  // task context so the AI knows who it's responding to.
41
42
  sender?: string;
43
+ status?: { from: string; to: string; detail?: string };
42
44
  }
43
45
 
44
46
  /** Tracker-agnostic comment */
@@ -130,6 +132,7 @@ export interface IssueTracker {
130
132
  deleteComment(ref: TrackerRef, commentId: string): Promise<void>;
131
133
  listComments(ref: TrackerRef): Promise<TrackerComment[]>;
132
134
  closeIssue(ref: TrackerRef): Promise<void>;
135
+ updateStatus(ref: TrackerRef, status: string, detail?: string): Promise<void>;
133
136
 
134
137
  setReaction(ref: TrackerRef, commentId: string, content: string, remove?: boolean): Promise<void>;
135
138