ework-daemon 0.4.23 → 0.4.25

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.23",
3
+ "version": "0.4.25",
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
@@ -36,6 +36,7 @@ export const configSchema = z.object({
36
36
  }),
37
37
  work: z.object({
38
38
  capacity: z.coerce.number().int().positive().default(4),
39
+ maxConcurrent: z.coerce.number().int().positive().default(4),
39
40
  heartbeatMs: z.coerce.number().int().positive().default(10_000),
40
41
  leaseTtlMs: z.coerce.number().int().positive().default(60_000),
41
42
  }),
@@ -85,8 +86,10 @@ const TEST_DEFAULTS = {
85
86
  };
86
87
 
87
88
  function readWorkSection() {
89
+ const capacity = process.env.WORK_DAEMON_CAPACITY ? Number(process.env.WORK_DAEMON_CAPACITY) : 4;
88
90
  return {
89
- capacity: process.env.WORK_DAEMON_CAPACITY ? Number(process.env.WORK_DAEMON_CAPACITY) : 4,
91
+ capacity,
92
+ maxConcurrent: process.env.WORK_MAX_CONCURRENT ? Number(process.env.WORK_MAX_CONCURRENT) : capacity,
90
93
  heartbeatMs: process.env.WORK_DAEMON_HEARTBEAT_MS ? Number(process.env.WORK_DAEMON_HEARTBEAT_MS) : 10_000,
91
94
  leaseTtlMs: process.env.WORK_DAEMON_LEASE_TTL_MS ? Number(process.env.WORK_DAEMON_LEASE_TTL_MS) : 60_000,
92
95
  };
package/src/gitea.ts CHANGED
@@ -75,8 +75,8 @@ export class GiteaClient {
75
75
  status,
76
76
  detail,
77
77
  }, true);
78
- } catch {
79
- // Status callback is best-effort — don't block processing on API failure.
78
+ } catch (e) {
79
+ console.warn("[gitea] updateIssueStatus failed:", (e as Error).message);
80
80
  }
81
81
  }
82
82
 
package/src/op.ts CHANGED
@@ -299,6 +299,14 @@ export class Store {
299
299
  return row ? rowToMessage(row) : undefined;
300
300
  }
301
301
 
302
+ async getGlobalPendingMessages(limit: number): Promise<Message[]> {
303
+ const rows = await getDB().all<MessageRow>(
304
+ "SELECT * FROM {{messages}} WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?",
305
+ [limit]
306
+ );
307
+ return rows.map(rowToMessage);
308
+ }
309
+
302
310
  async updateMessageStatus(id: string, status: Message["status"], error?: string): Promise<void> {
303
311
  const row = await getDB().get<MessageRow>("SELECT * FROM {{messages}} WHERE uid = ?", [id]);
304
312
  const attempts = row ? row.attempts + (status === "failed" ? 1 : 0) : 0;
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);
@@ -884,6 +889,11 @@ export class Engine {
884
889
  return;
885
890
  }
886
891
 
892
+ if (this.running.size >= this.cfg.work.maxConcurrent) {
893
+ log.info(`engine: concurrency limit reached (${this.running.size}/${this.cfg.work.maxConcurrent}), message ${msg.id.slice(0, 8)} queued for ${k}`);
894
+ return;
895
+ }
896
+
887
897
  // Not running — execute directly
888
898
  await this.executeMessage(k, session, issue, msg);
889
899
  }
@@ -1163,6 +1173,7 @@ export class Engine {
1163
1173
  if (exitCode === null) {
1164
1174
  log.info(`engine: spawn failed for ${k}, skipping completion check`);
1165
1175
  await this.store.updateSession(session.id, { opencodePid: undefined });
1176
+ void tracker.updateStatus(ref, "failed", "spawn failed");
1166
1177
  await this.deactivateIfIdle(k, session, issue);
1167
1178
  return;
1168
1179
  }
@@ -1185,6 +1196,7 @@ export class Engine {
1185
1196
  this.nudgeRounds.delete(k);
1186
1197
  this.emptyResponseRounds.delete(k);
1187
1198
  await this.persistRuntimeState(session.id);
1199
+ void tracker.updateStatus(ref, "");
1188
1200
  } else {
1189
1201
  const sessionOutput = await this.checkSessionOutput(session.opencodeSessionId);
1190
1202
  const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
@@ -1207,6 +1219,7 @@ export class Engine {
1207
1219
  this.emptyResponseRounds.delete(k);
1208
1220
  this.nudgeRounds.delete(k);
1209
1221
  await tracker.createComment(ref, `[system] ❌ **${session.name}** 模型返回空响应(0 token),已重试 ${emptyRound} 次。请检查模型配置或稍后重试。`).catch(() => {});
1222
+ void tracker.updateStatus(ref, "failed", "empty model response");
1210
1223
  } else {
1211
1224
  const nudgeRound = this.nudgeRounds.get(k) ?? 0;
1212
1225
  if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
@@ -1225,6 +1238,7 @@ export class Engine {
1225
1238
  this.nudgeRounds.delete(k);
1226
1239
  const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
1227
1240
  await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
1241
+ void tracker.updateStatus(ref, "failed", detail);
1228
1242
  }
1229
1243
  }
1230
1244
 
@@ -1258,13 +1272,38 @@ export class Engine {
1258
1272
  if (nextMsg) {
1259
1273
  const current = await this.store.getSession(session.id);
1260
1274
  if (current && current.state !== "idle") {
1261
- await this.dequeueOrIdle(k, current, issue, nextMsg);
1262
- return;
1275
+ if (this.running.size >= this.cfg.work.maxConcurrent) {
1276
+ log.info(`engine: concurrency limit (${this.running.size}/${this.cfg.work.maxConcurrent}), keeping msg ${nextMsg.id.slice(0, 8)} pending for ${k}`);
1277
+ } else {
1278
+ await this.dequeueOrIdle(k, current, issue, nextMsg);
1279
+ return;
1280
+ }
1263
1281
  }
1264
1282
  }
1265
1283
 
1266
1284
  this.clearRuntimeState(k);
1267
1285
  await this.store.updateSession(session.id, { state: "idle" });
1286
+
1287
+ void this.drainGlobalPending();
1288
+ }
1289
+
1290
+ private async drainGlobalPending(): Promise<void> {
1291
+ const slotsAvailable = this.cfg.work.maxConcurrent - this.running.size;
1292
+ if (slotsAvailable <= 0) return;
1293
+ const pending = await this.store.getGlobalPendingMessages(slotsAvailable);
1294
+ for (const msg of pending) {
1295
+ if (this.running.size >= this.cfg.work.maxConcurrent) break;
1296
+ const session = await this.store.getSession(msg.sessionId);
1297
+ if (!session || session.state === "running") continue;
1298
+ const issue = await this.store.getIssue(session.issueId);
1299
+ if (!issue || issue.state === "closed") continue;
1300
+ const k = this.sessionKey(session, issue);
1301
+ if (this.running.has(k)) continue;
1302
+ const won = await this.store.claimMessage(msg.id);
1303
+ if (!won) continue;
1304
+ log.info(`engine: drainGlobalPending picked up msg ${msg.id.slice(0, 8)} for ${k}`);
1305
+ await this.dequeueOrIdle(k, session, issue, msg);
1306
+ }
1268
1307
  }
1269
1308
 
1270
1309
  private async dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
@@ -140,6 +140,7 @@ export class GiteaTracker implements IssueTracker {
140
140
  };
141
141
 
142
142
  const issueUser = issue.user as Record<string, string>;
143
+ const aiStatus = typeof issue.ai_status === "string" ? issue.ai_status : "";
143
144
 
144
145
  if (action === "opened" || action === "reopened") {
145
146
  return {
@@ -150,6 +151,7 @@ export class GiteaTracker implements IssueTracker {
150
151
  body: (issue.body as string) ?? "",
151
152
  state: (issue.state as string) ?? "open",
152
153
  author: issueUser?.login ?? "",
154
+ ai_status: aiStatus,
153
155
  },
154
156
  model,
155
157
  cloneUrl,
@@ -167,6 +169,7 @@ export class GiteaTracker implements IssueTracker {
167
169
  body: (issue.body as string) ?? "",
168
170
  state: (issue.state as string) ?? "open",
169
171
  author: issueUser?.login ?? "",
172
+ ai_status: aiStatus,
170
173
  },
171
174
  comment: {
172
175
  id: String(comment.id),
@@ -188,6 +191,7 @@ export class GiteaTracker implements IssueTracker {
188
191
  body: (issue.body as string) ?? "",
189
192
  state: "closed",
190
193
  author: issueUser?.login ?? "",
194
+ ai_status: aiStatus,
191
195
  },
192
196
  model,
193
197
  cloneUrl,
@@ -205,6 +209,7 @@ export class GiteaTracker implements IssueTracker {
205
209
  body: (issue.body as string) ?? "",
206
210
  state: (issue.state as string) ?? "open",
207
211
  author: issueUser?.login ?? "",
212
+ ai_status: aiStatus,
208
213
  },
209
214
  status: {
210
215
  from: status?.from ?? "",
@@ -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;