@shipers-dev/multi 0.9.1 → 0.9.3

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/dist/index.js CHANGED
@@ -5169,6 +5169,25 @@ class RequestError extends Error {
5169
5169
  // src/acp-runner.ts
5170
5170
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
5171
5171
  import { dirname } from "path";
5172
+ function fmtErr(e) {
5173
+ if (e == null)
5174
+ return "unknown error";
5175
+ if (typeof e === "string")
5176
+ return e;
5177
+ if (e instanceof Error)
5178
+ return e.message;
5179
+ if (typeof e === "object") {
5180
+ const inner = e.error ?? e.cause ?? e;
5181
+ const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
5182
+ const code = inner?.code ?? e.code ?? e.status;
5183
+ if (msg)
5184
+ return code ? `${msg} (code ${code})` : String(msg);
5185
+ try {
5186
+ return JSON.stringify(e).slice(0, 500);
5187
+ } catch {}
5188
+ }
5189
+ return String(e);
5190
+ }
5172
5191
  async function runAcp(opts) {
5173
5192
  const cleanEnv = {};
5174
5193
  for (const [k, v] of Object.entries(process.env)) {
@@ -5221,7 +5240,7 @@ async function runAcp(opts) {
5221
5240
  `) : content;
5222
5241
  return { content: sliced };
5223
5242
  } catch (e) {
5224
- throw new Error(`readTextFile failed: ${String(e)}`);
5243
+ throw new Error(`readTextFile failed: ${fmtErr(e)}`);
5225
5244
  }
5226
5245
  },
5227
5246
  async writeTextFile(params) {
@@ -5232,7 +5251,7 @@ async function runAcp(opts) {
5232
5251
  writeFileSync(params.path, params.content, "utf8");
5233
5252
  return {};
5234
5253
  } catch (e) {
5235
- throw new Error(`writeTextFile failed: ${String(e)}`);
5254
+ throw new Error(`writeTextFile failed: ${fmtErr(e)}`);
5236
5255
  }
5237
5256
  }
5238
5257
  };
@@ -5259,7 +5278,7 @@ async function runAcp(opts) {
5259
5278
  await conn.loadSession?.({ sessionId: activeSessionId, cwd: opts.cwd || process.cwd(), mcpServers: [] });
5260
5279
  await opts.onEvent({ event_type: "progress", payload: { message: `ACP session ${activeSessionId.slice(0, 8)} resumed` } });
5261
5280
  } catch (e) {
5262
- await opts.onEvent({ event_type: "progress", payload: { message: `load_session failed; starting new. ${String(e)}` } });
5281
+ await opts.onEvent({ event_type: "progress", payload: { message: `load_session failed; starting new. ${fmtErr(e)}` } });
5263
5282
  const { sessionId } = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] });
5264
5283
  activeSessionId = sessionId;
5265
5284
  if (opts.onSession)
@@ -5286,7 +5305,7 @@ async function runAcp(opts) {
5286
5305
  res = await runPrompt();
5287
5306
  stopReason = res.stopReason;
5288
5307
  } catch (e) {
5289
- await opts.onEvent({ event_type: "error", payload: { message: `retry with fresh session failed: ${String(e)}` } });
5308
+ await opts.onEvent({ event_type: "error", payload: { message: `retry with fresh session failed: ${fmtErr(e)}` } });
5290
5309
  }
5291
5310
  }
5292
5311
  if (chunkCount === 0) {
@@ -5689,7 +5708,7 @@ var LOG_PATH2 = join3(MULTI_DIR, "logs", "agent.log");
5689
5708
  var SKILLS_DIR = join3(MULTI_DIR, "skills");
5690
5709
  var STOP_PATH = join3(MULTI_DIR, "stop.flag");
5691
5710
  var TASKS_DB_PATH = join3(MULTI_DIR, "tasks.db");
5692
- var VERSION = "0.9.1";
5711
+ var VERSION = "0.9.2";
5693
5712
  var COMMANDS = {
5694
5713
  setup: "Register this device with a workspace",
5695
5714
  connect: "Connect device to realtime hub and execute assigned tasks",
@@ -5928,21 +5947,48 @@ async function cmdConnect(apiUrl, config) {
5928
5947
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
5929
5948
  const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
5930
5949
  const running = new Map;
5950
+ function resolvePayloadIds(row) {
5951
+ let agent_id = row.agent_id;
5952
+ let issue_id = row.issue_id;
5953
+ if (!agent_id || !issue_id) {
5954
+ try {
5955
+ const p = JSON.parse(row.payload);
5956
+ agent_id ??= p?.agent_id ?? null;
5957
+ issue_id ??= p?.issue_id ?? null;
5958
+ } catch {}
5959
+ }
5960
+ return { agent_id, issue_id };
5961
+ }
5931
5962
  function pickNext() {
5932
- const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean);
5933
- const notIn = busyAgents.length ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))` : "";
5934
- const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
5935
- return db.query(sql).get(...busyAgents);
5963
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v) => !!v);
5964
+ const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v) => !!v);
5965
+ const clauses = [];
5966
+ const binds = [];
5967
+ if (busyAgents.length) {
5968
+ clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))`);
5969
+ binds.push(...busyAgents);
5970
+ }
5971
+ if (busyIssues.length) {
5972
+ clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => "?").join(",")}))`);
5973
+ binds.push(...busyIssues);
5974
+ }
5975
+ const where = clauses.length ? `AND ${clauses.join(" AND ")}` : "";
5976
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
5977
+ return db.query(sql).get(...binds);
5936
5978
  }
5937
5979
  function schedule() {
5938
5980
  while (running.size < MAX_DEVICE) {
5939
5981
  const row = pickNext();
5940
5982
  if (!row)
5941
5983
  return;
5984
+ const ids = resolvePayloadIds(row);
5985
+ if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id))
5986
+ return;
5987
+ if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id))
5988
+ return;
5942
5989
  db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5943
- const entry = { agentId: row.agent_id || "", startedAt: Date.now(), child: null, worktreePath: "" };
5944
- const issueKey = row.issue_id || row.id;
5945
- running.set(issueKey, entry);
5990
+ const entry = { agentId: ids.agent_id || "", issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: "" };
5991
+ running.set(row.id, entry);
5946
5992
  (async () => {
5947
5993
  try {
5948
5994
  const task = JSON.parse(row.payload);
@@ -5952,7 +5998,7 @@ async function cmdConnect(apiUrl, config) {
5952
5998
  log(`task ${row.id} error: ${String(e)}`);
5953
5999
  db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5954
6000
  } finally {
5955
- running.delete(issueKey);
6001
+ running.delete(row.id);
5956
6002
  queueMicrotask(() => schedule());
5957
6003
  }
5958
6004
  })();
@@ -5995,22 +6041,24 @@ async function cmdConnect(apiUrl, config) {
5995
6041
  const { issue_id } = await req.json();
5996
6042
  if (!issue_id)
5997
6043
  return Response.json({ error: "issue_id required" }, { status: 400 });
5998
- const entry = running.get(issue_id);
5999
- if (!entry) {
6044
+ const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
6045
+ if (!entries.length) {
6000
6046
  db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
6001
6047
  await markStopped(apiUrl, issue_id, "stopped before start");
6002
6048
  return Response.json({ ok: true, state: "queued-cancelled" });
6003
6049
  }
6004
- entry.stopped = true;
6005
- entry.stopReason = "user requested";
6006
- try {
6007
- entry.child?.kill("SIGTERM");
6008
- } catch {}
6009
- setTimeout(() => {
6050
+ for (const entry of entries) {
6051
+ entry.stopped = true;
6052
+ entry.stopReason = "user requested";
6010
6053
  try {
6011
- entry.child?.kill("SIGKILL");
6054
+ entry.child?.kill("SIGTERM");
6012
6055
  } catch {}
6013
- }, 5000);
6056
+ setTimeout(() => {
6057
+ try {
6058
+ entry.child?.kill("SIGKILL");
6059
+ } catch {}
6060
+ }, 5000);
6061
+ }
6014
6062
  return Response.json({ ok: true, state: "running-signalled" });
6015
6063
  } catch (e) {
6016
6064
  return Response.json({ error: String(e) }, { status: 400 });
@@ -6164,7 +6212,7 @@ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6164
6212
  worktreeBranch = wt.branch;
6165
6213
  await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
6166
6214
  } catch (e) {
6167
- await postStream(apiUrl, issueId, "worktree_error", { message: String(e) });
6215
+ await postStream(apiUrl, issueId, "worktree_error", { message: fmtError(e) });
6168
6216
  }
6169
6217
  }
6170
6218
  log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ""}]` : ""}`);
@@ -6633,14 +6681,15 @@ ${userPart}` : userPart;
6633
6681
  log(` \u2713 ${task.key} complete`);
6634
6682
  }
6635
6683
  } catch (e) {
6684
+ const msg = fmtError(e);
6636
6685
  if (ctx?.runEntry?.stopped) {
6637
6686
  await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
6638
- log(` \u23F9 ${task.key} stopped (${String(e)})`);
6687
+ log(` \u23F9 ${task.key} stopped (${msg})`);
6639
6688
  } else {
6640
- await postStream(apiUrl, issueId, "error", { message: String(e) });
6641
- await postComment(`\u274C spawn error: ${String(e)}`);
6689
+ await postStream(apiUrl, issueId, "error", { message: msg });
6690
+ await postComment(`\u274C spawn error: ${msg}`);
6642
6691
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6643
- log(` \u2717 ${task.key} failed: ${String(e)}`);
6692
+ log(` \u2717 ${task.key} failed: ${msg}`);
6644
6693
  }
6645
6694
  }
6646
6695
  }
@@ -6802,6 +6851,25 @@ function stripMd(s) {
6802
6851
  function stripSelfMention(prompt, _agentType) {
6803
6852
  return prompt.replace(/^@[A-Za-z0-9_\-]+\s*/, "").trim();
6804
6853
  }
6854
+ function fmtError(e) {
6855
+ if (e == null)
6856
+ return "unknown error";
6857
+ if (typeof e === "string")
6858
+ return e;
6859
+ if (e instanceof Error)
6860
+ return e.stack ? `${e.message}` : String(e);
6861
+ if (typeof e === "object") {
6862
+ const inner = e.error ?? e.cause ?? e;
6863
+ const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
6864
+ const code = inner?.code ?? e.code ?? e.status;
6865
+ if (msg)
6866
+ return code ? `${msg} (code ${code})` : String(msg);
6867
+ try {
6868
+ return JSON.stringify(e).slice(0, 500);
6869
+ } catch {}
6870
+ }
6871
+ return String(e);
6872
+ }
6805
6873
  function statusIcon(status) {
6806
6874
  switch (status) {
6807
6875
  case "pending":
@@ -6956,7 +7024,7 @@ Context (original task ${task.key}): ${task.title}` : base || task.title;
6956
7024
  try {
6957
7025
  proc = Bun.spawn([agent.path, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd: task?.working_dir || undefined });
6958
7026
  } catch (e) {
6959
- yield { event_type: "error", payload: { message: `spawn failed: ${String(e)}` } };
7027
+ yield { event_type: "error", payload: { message: `spawn failed: ${fmtError(e)}` } };
6960
7028
  return;
6961
7029
  }
6962
7030
  const queue = [];
@@ -7249,6 +7317,8 @@ function openTasksDb() {
7249
7317
  CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
7250
7318
  `);
7251
7319
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
7320
+ db.run("UPDATE tasks SET agent_id = json_extract(payload, '$.agent_id') WHERE agent_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
7321
+ db.run("UPDATE tasks SET issue_id = json_extract(payload, '$.issue_id') WHERE issue_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
7252
7322
  return db;
7253
7323
  }
7254
7324
  function loadConfig() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/acp-runner.ts CHANGED
@@ -8,6 +8,20 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
8
8
  import { dirname } from 'path';
9
9
  import { apiClient } from './client';
10
10
 
11
+ function fmtErr(e: any): string {
12
+ if (e == null) return 'unknown error';
13
+ if (typeof e === 'string') return e;
14
+ if (e instanceof Error) return e.message;
15
+ if (typeof e === 'object') {
16
+ const inner = e.error ?? e.cause ?? e;
17
+ const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
18
+ const code = inner?.code ?? e.code ?? e.status;
19
+ if (msg) return code ? `${msg} (code ${code})` : String(msg);
20
+ try { return JSON.stringify(e).slice(0, 500); } catch {}
21
+ }
22
+ return String(e);
23
+ }
24
+
11
25
  export type AcpEvent =
12
26
  | { event_type: 'progress'; payload: any }
13
27
  | { event_type: 'assistant_text'; payload: { text: string } }
@@ -79,7 +93,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
79
93
  : content;
80
94
  return { content: sliced };
81
95
  } catch (e) {
82
- throw new Error(`readTextFile failed: ${String(e)}`);
96
+ throw new Error(`readTextFile failed: ${fmtErr(e)}`);
83
97
  }
84
98
  },
85
99
  async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
@@ -89,7 +103,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
89
103
  writeFileSync(params.path, params.content, 'utf8');
90
104
  return {};
91
105
  } catch (e) {
92
- throw new Error(`writeTextFile failed: ${String(e)}`);
106
+ throw new Error(`writeTextFile failed: ${fmtErr(e)}`);
93
107
  }
94
108
  },
95
109
  };
@@ -118,7 +132,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
118
132
  await conn.loadSession?.({ sessionId: activeSessionId, cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
119
133
  await opts.onEvent({ event_type: 'progress', payload: { message: `ACP session ${activeSessionId.slice(0, 8)} resumed` } });
120
134
  } catch (e) {
121
- await opts.onEvent({ event_type: 'progress', payload: { message: `load_session failed; starting new. ${String(e)}` } });
135
+ await opts.onEvent({ event_type: 'progress', payload: { message: `load_session failed; starting new. ${fmtErr(e)}` } });
122
136
  const { sessionId } = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
123
137
  activeSessionId = sessionId;
124
138
  if (opts.onSession) await opts.onSession(sessionId);
@@ -148,7 +162,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
148
162
  res = await runPrompt();
149
163
  stopReason = (res as any).stopReason;
150
164
  } catch (e) {
151
- await opts.onEvent({ event_type: 'error', payload: { message: `retry with fresh session failed: ${String(e)}` } });
165
+ await opts.onEvent({ event_type: 'error', payload: { message: `retry with fresh session failed: ${fmtErr(e)}` } });
152
166
  }
153
167
  }
154
168
 
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
18
18
  const SKILLS_DIR = join(MULTI_DIR, 'skills');
19
19
  const STOP_PATH = join(MULTI_DIR, 'stop.flag');
20
20
  const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
21
- const VERSION = '0.9.1';
21
+ const VERSION = '0.9.2';
22
22
 
23
23
  const COMMANDS = {
24
24
  setup: 'Register this device with a workspace',
@@ -261,26 +261,52 @@ async function cmdConnect(apiUrl: string, config: Config) {
261
261
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
262
262
 
263
263
  const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
264
+ // Keyed by task row id — unique per dispatch. Duplicate issue_id / agent_id still
265
+ // serialize via busyAgents/busyIssues filters.
264
266
  const running = new Map<string, RunEntry>();
265
267
 
268
+ function resolvePayloadIds(row: { payload: string; agent_id: string | null; issue_id: string | null }): { agent_id: string | null; issue_id: string | null } {
269
+ let agent_id = row.agent_id;
270
+ let issue_id = row.issue_id;
271
+ if (!agent_id || !issue_id) {
272
+ try {
273
+ const p = JSON.parse(row.payload) as any;
274
+ agent_id ??= p?.agent_id ?? null;
275
+ issue_id ??= p?.issue_id ?? null;
276
+ } catch {}
277
+ }
278
+ return { agent_id, issue_id };
279
+ }
280
+
266
281
  function pickNext(): { id: string; payload: string; agent_id: string | null; issue_id: string | null } | null {
267
- const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean) as string[];
268
- // Emulate NOT IN (:list) via string construction (safe: only ids from DB).
269
- const notIn = busyAgents.length
270
- ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`
271
- : '';
272
- const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
273
- return db.query(sql).get(...busyAgents) as any;
282
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v): v is string => !!v);
283
+ const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v): v is string => !!v);
284
+ const clauses: string[] = [];
285
+ const binds: string[] = [];
286
+ if (busyAgents.length) {
287
+ clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`);
288
+ binds.push(...busyAgents);
289
+ }
290
+ if (busyIssues.length) {
291
+ clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => '?').join(',')}))`);
292
+ binds.push(...busyIssues);
293
+ }
294
+ const where = clauses.length ? `AND ${clauses.join(' AND ')}` : '';
295
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
296
+ return db.query(sql).get(...binds) as any;
274
297
  }
275
298
 
276
299
  function schedule() {
277
300
  while (running.size < MAX_DEVICE) {
278
301
  const row = pickNext();
279
302
  if (!row) return;
303
+ const ids = resolvePayloadIds(row);
304
+ // Defensive: if DB saw NULL agent/issue but payload has them, skip if busy.
305
+ if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id)) return;
306
+ if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id)) return;
280
307
  db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
281
- const entry: RunEntry = { agentId: row.agent_id || '', startedAt: Date.now(), child: null, worktreePath: '' };
282
- const issueKey = row.issue_id || row.id;
283
- running.set(issueKey, entry);
308
+ const entry: RunEntry = { agentId: ids.agent_id || '', issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: '' };
309
+ running.set(row.id, entry);
284
310
  void (async () => {
285
311
  try {
286
312
  const task = JSON.parse(row.payload);
@@ -290,7 +316,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
290
316
  log(`task ${row.id} error: ${String(e)}`);
291
317
  db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
292
318
  } finally {
293
- running.delete(issueKey);
319
+ running.delete(row.id);
294
320
  queueMicrotask(() => schedule());
295
321
  }
296
322
  })();
@@ -334,16 +360,18 @@ async function cmdConnect(apiUrl: string, config: Config) {
334
360
  try {
335
361
  const { issue_id } = await req.json() as { issue_id: string };
336
362
  if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
337
- const entry = running.get(issue_id);
338
- if (!entry) {
363
+ const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
364
+ if (!entries.length) {
339
365
  db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
340
366
  await markStopped(apiUrl, issue_id, 'stopped before start');
341
367
  return Response.json({ ok: true, state: 'queued-cancelled' });
342
368
  }
343
- entry.stopped = true;
344
- entry.stopReason = 'user requested';
345
- try { entry.child?.kill('SIGTERM'); } catch {}
346
- setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
369
+ for (const entry of entries) {
370
+ entry.stopped = true;
371
+ entry.stopReason = 'user requested';
372
+ try { entry.child?.kill('SIGTERM'); } catch {}
373
+ setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
374
+ }
347
375
  return Response.json({ ok: true, state: 'running-signalled' });
348
376
  } catch (e) {
349
377
  return Response.json({ error: String(e) }, { status: 400 });
@@ -462,6 +490,7 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
462
490
 
463
491
  interface RunEntry {
464
492
  agentId: string;
493
+ issueId: string | null;
465
494
  startedAt: number;
466
495
  child: any | null;
467
496
  worktreePath: string;
@@ -501,7 +530,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
501
530
  worktreeBranch = wt.branch;
502
531
  await postStream(apiUrl, issueId, 'worktree_created', { path: wt.path, branch: wt.branch, reused: !wt.created });
503
532
  } catch (e) {
504
- await postStream(apiUrl, issueId, 'worktree_error', { message: String(e) });
533
+ await postStream(apiUrl, issueId, 'worktree_error', { message: fmtError(e) });
505
534
  }
506
535
  }
507
536
 
@@ -875,14 +904,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
875
904
  } else if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
876
905
  else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
877
906
  } catch (e) {
907
+ const msg = fmtError(e);
878
908
  if (ctx?.runEntry?.stopped) {
879
909
  await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
880
- log(` ⏹ ${task.key} stopped (${String(e)})`);
910
+ log(` ⏹ ${task.key} stopped (${msg})`);
881
911
  } else {
882
- await postStream(apiUrl, issueId, 'error', { message: String(e) });
883
- await postComment(`❌ spawn error: ${String(e)}`);
912
+ await postStream(apiUrl, issueId, 'error', { message: msg });
913
+ await postComment(`❌ spawn error: ${msg}`);
884
914
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
885
- log(` ✗ ${task.key} failed: ${String(e)}`);
915
+ log(` ✗ ${task.key} failed: ${msg}`);
886
916
  }
887
917
  }
888
918
  }
@@ -1040,6 +1070,23 @@ function stripSelfMention(prompt: string, _agentType?: string): string {
1040
1070
  return prompt.replace(/^@[A-Za-z0-9_\-]+\s*/, '').trim();
1041
1071
  }
1042
1072
 
1073
+ // Best-effort error → string. Plain Error, ACP error envelopes ({error:{message,code,data}}),
1074
+ // fetch Response-shaped rejections, and arbitrary objects all stringify to something readable
1075
+ // instead of "[object Object]".
1076
+ function fmtError(e: any): string {
1077
+ if (e == null) return 'unknown error';
1078
+ if (typeof e === 'string') return e;
1079
+ if (e instanceof Error) return e.stack ? `${e.message}` : String(e);
1080
+ if (typeof e === 'object') {
1081
+ const inner = e.error ?? e.cause ?? e;
1082
+ const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
1083
+ const code = inner?.code ?? e.code ?? e.status;
1084
+ if (msg) return code ? `${msg} (code ${code})` : String(msg);
1085
+ try { return JSON.stringify(e).slice(0, 500); } catch {}
1086
+ }
1087
+ return String(e);
1088
+ }
1089
+
1043
1090
  function statusIcon(status?: string): string {
1044
1091
  switch (status) {
1045
1092
  case 'pending': return '⏳';
@@ -1183,7 +1230,7 @@ function makeCliRunner(agent: { type: string; path: string }): Runner {
1183
1230
  try {
1184
1231
  proc = Bun.spawn([agent.path, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', cwd: (task as any)?.working_dir || undefined });
1185
1232
  } catch (e) {
1186
- yield { event_type: 'error', payload: { message: `spawn failed: ${String(e)}` } };
1233
+ yield { event_type: 'error', payload: { message: `spawn failed: ${fmtError(e)}` } };
1187
1234
  return;
1188
1235
  }
1189
1236
 
@@ -1440,6 +1487,11 @@ function openTasksDb(): Database {
1440
1487
  `);
1441
1488
  // Old rows used 'pending'; normalize to 'queued'.
1442
1489
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
1490
+ // Backfill agent_id/issue_id from payload JSON for rows predating the columns.
1491
+ // Without this, the scheduler cannot serialize per-agent / per-issue and fires
1492
+ // concurrent dispatches for the same agent on the same issue.
1493
+ db.run("UPDATE tasks SET agent_id = json_extract(payload, '$.agent_id') WHERE agent_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
1494
+ db.run("UPDATE tasks SET issue_id = json_extract(payload, '$.issue_id') WHERE issue_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
1443
1495
  return db;
1444
1496
  }
1445
1497
 
@@ -1,11 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "mcp__acp__Write",
5
- "Bash(npm install:*)",
6
- "Bash(npx tsc:*)",
7
- "Bash(npx vite build:*)",
8
- "mcp__acp__Edit"
9
- ]
10
- }
11
- }