@shipers-dev/multi 0.9.2 → 0.9.4

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.
Files changed (3) hide show
  1. package/dist/index.js +156 -21
  2. package/package.json +1 -1
  3. package/src/index.ts +118 -18
package/dist/index.js CHANGED
@@ -5700,15 +5700,35 @@ async function ensureWorktree(workingDir, issueKey) {
5700
5700
  import { parseArgs } from "util";
5701
5701
  import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
5702
5702
  import { join as join3, dirname as dirname2 } from "path";
5703
+ // package.json
5704
+ var package_default = {
5705
+ name: "@shipers-dev/multi",
5706
+ version: "0.9.4",
5707
+ type: "module",
5708
+ bin: {
5709
+ "multi-agent": "./dist/index.js"
5710
+ },
5711
+ scripts: {
5712
+ dev: "bun run src/index.ts",
5713
+ build: "bun build src/index.ts --outdir=dist --target=bun"
5714
+ },
5715
+ dependencies: {
5716
+ "@zed-industries/agent-client-protocol": "^0.4.5",
5717
+ "@zed-industries/claude-code-acp": "^0.16.2"
5718
+ }
5719
+ };
5720
+
5721
+ // src/index.ts
5703
5722
  var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
5704
5723
  var MULTI_DIR = join3(HOME2, ".multi");
5705
5724
  var CONFIG_PATH = join3(MULTI_DIR, "config.json");
5706
5725
  var PID_PATH = join3(MULTI_DIR, "agent.pid");
5726
+ var PORT_PATH = join3(MULTI_DIR, "agent.port");
5707
5727
  var LOG_PATH2 = join3(MULTI_DIR, "logs", "agent.log");
5708
5728
  var SKILLS_DIR = join3(MULTI_DIR, "skills");
5709
5729
  var STOP_PATH = join3(MULTI_DIR, "stop.flag");
5710
5730
  var TASKS_DB_PATH = join3(MULTI_DIR, "tasks.db");
5711
- var VERSION = "0.9.2";
5731
+ var VERSION = package_default.version;
5712
5732
  var COMMANDS = {
5713
5733
  setup: "Register this device with a workspace",
5714
5734
  connect: "Connect device to realtime hub and execute assigned tasks",
@@ -5716,7 +5736,8 @@ var COMMANDS = {
5716
5736
  status: "Show current status",
5717
5737
  stop: "Stop the running daemon",
5718
5738
  restart: "Stop and relaunch the daemon in background",
5719
- logs: "View execution logs"
5739
+ logs: "View execution logs",
5740
+ reset: "Reset acpx session for an issue (--issue <id>)"
5720
5741
  };
5721
5742
  function ensureDirs() {
5722
5743
  for (const d of [MULTI_DIR, join3(MULTI_DIR, "logs"), SKILLS_DIR]) {
@@ -5746,7 +5767,8 @@ async function main() {
5746
5767
  name: { type: "string" },
5747
5768
  workspace: { type: "string" },
5748
5769
  agent: { type: "string" },
5749
- api: { type: "string" }
5770
+ api: { type: "string" },
5771
+ issue: { type: "string" }
5750
5772
  },
5751
5773
  allowPositionals: true,
5752
5774
  strict: false
@@ -5787,6 +5809,9 @@ async function main() {
5787
5809
  case "logs":
5788
5810
  await cmdLogs();
5789
5811
  break;
5812
+ case "reset":
5813
+ await cmdReset(args.values.issue);
5814
+ break;
5790
5815
  default:
5791
5816
  console.error(`Unknown command: ${command}`);
5792
5817
  printHelp();
@@ -5807,6 +5832,7 @@ Commands:
5807
5832
  stop ${COMMANDS.stop}
5808
5833
  restart ${COMMANDS.restart}
5809
5834
  logs ${COMMANDS.logs}
5835
+ reset ${COMMANDS.reset}
5810
5836
 
5811
5837
  Options:
5812
5838
  --name <name> Device name
@@ -5947,21 +5973,48 @@ async function cmdConnect(apiUrl, config) {
5947
5973
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
5948
5974
  const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
5949
5975
  const running = new Map;
5976
+ function resolvePayloadIds(row) {
5977
+ let agent_id = row.agent_id;
5978
+ let issue_id = row.issue_id;
5979
+ if (!agent_id || !issue_id) {
5980
+ try {
5981
+ const p = JSON.parse(row.payload);
5982
+ agent_id ??= p?.agent_id ?? null;
5983
+ issue_id ??= p?.issue_id ?? null;
5984
+ } catch {}
5985
+ }
5986
+ return { agent_id, issue_id };
5987
+ }
5950
5988
  function pickNext() {
5951
- const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean);
5952
- const notIn = busyAgents.length ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))` : "";
5953
- const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
5954
- return db.query(sql).get(...busyAgents);
5989
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v) => !!v);
5990
+ const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v) => !!v);
5991
+ const clauses = [];
5992
+ const binds = [];
5993
+ if (busyAgents.length) {
5994
+ clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))`);
5995
+ binds.push(...busyAgents);
5996
+ }
5997
+ if (busyIssues.length) {
5998
+ clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => "?").join(",")}))`);
5999
+ binds.push(...busyIssues);
6000
+ }
6001
+ const where = clauses.length ? `AND ${clauses.join(" AND ")}` : "";
6002
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
6003
+ return db.query(sql).get(...binds);
5955
6004
  }
5956
6005
  function schedule() {
5957
6006
  while (running.size < MAX_DEVICE) {
5958
6007
  const row = pickNext();
5959
6008
  if (!row)
5960
6009
  return;
6010
+ const ids = resolvePayloadIds(row);
6011
+ if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id))
6012
+ return;
6013
+ if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id))
6014
+ return;
5961
6015
  db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5962
- const entry = { agentId: row.agent_id || "", startedAt: Date.now(), child: null, worktreePath: "" };
5963
- const issueKey = row.issue_id || row.id;
5964
- running.set(issueKey, entry);
6016
+ const entry = { agentId: ids.agent_id || "", issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: "" };
6017
+ running.set(row.id, entry);
5965
6018
  (async () => {
5966
6019
  try {
5967
6020
  const task = JSON.parse(row.payload);
@@ -5971,7 +6024,7 @@ async function cmdConnect(apiUrl, config) {
5971
6024
  log(`task ${row.id} error: ${String(e)}`);
5972
6025
  db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5973
6026
  } finally {
5974
- running.delete(issueKey);
6027
+ running.delete(row.id);
5975
6028
  queueMicrotask(() => schedule());
5976
6029
  }
5977
6030
  })();
@@ -6006,6 +6059,52 @@ async function cmdConnect(apiUrl, config) {
6006
6059
  }
6007
6060
  })();
6008
6061
  }
6062
+ if (url.pathname === "/reset_session" && req.method === "POST") {
6063
+ if (req.headers.get("authorization") !== expectedAuth)
6064
+ return new Response("unauthorized", { status: 401 });
6065
+ return (async () => {
6066
+ try {
6067
+ const { issue_id, agent_type } = await req.json();
6068
+ if (!issue_id)
6069
+ return Response.json({ error: "issue_id required" }, { status: 400 });
6070
+ const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
6071
+ for (const entry of entries) {
6072
+ entry.stopped = true;
6073
+ entry.stopReason = "session reset";
6074
+ try {
6075
+ entry.child?.kill("SIGTERM");
6076
+ } catch {}
6077
+ setTimeout(() => {
6078
+ try {
6079
+ entry.child?.kill("SIGKILL");
6080
+ } catch {}
6081
+ }, 3000);
6082
+ }
6083
+ const acpxTypes = agent_type ? [agent_type] : ["pi", "codex", "openclaw"];
6084
+ const sessionName = `issue-${issue_id}`;
6085
+ const cwd = process.env.HOME || process.cwd();
6086
+ const results = [];
6087
+ for (const t of acpxTypes) {
6088
+ try {
6089
+ const rm = Bun.spawn(["acpx", "--ttl", "0", "--cwd", cwd, t, "sessions", "close", sessionName], {
6090
+ stdout: "pipe",
6091
+ stderr: "pipe",
6092
+ stdin: "ignore"
6093
+ });
6094
+ const err = await new Response(rm.stderr).text();
6095
+ const exit = await rm.exited;
6096
+ results.push({ agent: t, exit, stderr: err.slice(0, 200) });
6097
+ } catch (e) {
6098
+ results.push({ agent: t, exit: -1, stderr: String(e).slice(0, 200) });
6099
+ }
6100
+ }
6101
+ log(`\uD83D\uDD04 reset_session ${issue_id} (killed=${entries.length}) ${results.map((r) => `${r.agent}:${r.exit}`).join(" ")}`);
6102
+ return Response.json({ ok: true, killed: entries.length, sessions: results });
6103
+ } catch (e) {
6104
+ return Response.json({ error: String(e) }, { status: 400 });
6105
+ }
6106
+ })();
6107
+ }
6009
6108
  if (url.pathname === "/stop" && req.method === "POST") {
6010
6109
  if (req.headers.get("authorization") !== expectedAuth)
6011
6110
  return new Response("unauthorized", { status: 401 });
@@ -6014,22 +6113,24 @@ async function cmdConnect(apiUrl, config) {
6014
6113
  const { issue_id } = await req.json();
6015
6114
  if (!issue_id)
6016
6115
  return Response.json({ error: "issue_id required" }, { status: 400 });
6017
- const entry = running.get(issue_id);
6018
- if (!entry) {
6116
+ const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
6117
+ if (!entries.length) {
6019
6118
  db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
6020
6119
  await markStopped(apiUrl, issue_id, "stopped before start");
6021
6120
  return Response.json({ ok: true, state: "queued-cancelled" });
6022
6121
  }
6023
- entry.stopped = true;
6024
- entry.stopReason = "user requested";
6025
- try {
6026
- entry.child?.kill("SIGTERM");
6027
- } catch {}
6028
- setTimeout(() => {
6122
+ for (const entry of entries) {
6123
+ entry.stopped = true;
6124
+ entry.stopReason = "user requested";
6029
6125
  try {
6030
- entry.child?.kill("SIGKILL");
6126
+ entry.child?.kill("SIGTERM");
6031
6127
  } catch {}
6032
- }, 5000);
6128
+ setTimeout(() => {
6129
+ try {
6130
+ entry.child?.kill("SIGKILL");
6131
+ } catch {}
6132
+ }, 5000);
6133
+ }
6033
6134
  return Response.json({ ok: true, state: "running-signalled" });
6034
6135
  } catch (e) {
6035
6136
  return Response.json({ error: String(e) }, { status: 400 });
@@ -6040,6 +6141,9 @@ async function cmdConnect(apiUrl, config) {
6040
6141
  }
6041
6142
  });
6042
6143
  log(`\uD83C\uDF10 Local server: http://127.0.0.1:${port}`);
6144
+ try {
6145
+ writeFileSync3(PORT_PATH, String(port));
6146
+ } catch {}
6043
6147
  const cf = Bun.spawn(["cloudflared", "tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`], {
6044
6148
  stdout: "pipe",
6045
6149
  stderr: "pipe",
@@ -6077,6 +6181,8 @@ async function cmdConnect(apiUrl, config) {
6077
6181
  unlinkSync(PID_PATH);
6078
6182
  if (existsSync3(STOP_PATH))
6079
6183
  unlinkSync(STOP_PATH);
6184
+ if (existsSync3(PORT_PATH))
6185
+ unlinkSync(PORT_PATH);
6080
6186
  db.close();
6081
6187
  log("\uD83D\uDC4B Disconnected");
6082
6188
  process.exit(0);
@@ -7199,6 +7305,33 @@ async function cmdStop() {
7199
7305
  console.log(`Sent SIGTERM to ${pid}`);
7200
7306
  } catch {}
7201
7307
  }
7308
+ async function cmdReset(issueId) {
7309
+ if (!issueId) {
7310
+ console.error("Usage: multi-agent reset --issue <issue_id>");
7311
+ process.exit(2);
7312
+ }
7313
+ if (!existsSync3(PORT_PATH)) {
7314
+ console.error("Daemon not running (no port file).");
7315
+ process.exit(1);
7316
+ }
7317
+ const port = Number(readFileSync3(PORT_PATH, "utf8").trim());
7318
+ const config = loadConfig();
7319
+ if (!config.dispatchSecret) {
7320
+ console.error("No dispatchSecret in config \u2014 run `multi-agent setup` first.");
7321
+ process.exit(1);
7322
+ }
7323
+ const res = await fetch(`http://127.0.0.1:${port}/reset_session`, {
7324
+ method: "POST",
7325
+ headers: { "content-type": "application/json", authorization: `Bearer ${config.dispatchSecret}` },
7326
+ body: JSON.stringify({ issue_id: issueId })
7327
+ });
7328
+ const body = await res.text();
7329
+ if (!res.ok) {
7330
+ console.error(`Reset failed (${res.status}): ${body}`);
7331
+ process.exit(1);
7332
+ }
7333
+ console.log(body);
7334
+ }
7202
7335
  async function cmdRestart(apiUrl) {
7203
7336
  if (existsSync3(PID_PATH)) {
7204
7337
  const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
@@ -7288,6 +7421,8 @@ function openTasksDb() {
7288
7421
  CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
7289
7422
  `);
7290
7423
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
7424
+ 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)");
7425
+ 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)");
7291
7426
  return db;
7292
7427
  }
7293
7428
  function loadConfig() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/index.ts CHANGED
@@ -9,16 +9,18 @@ import { ensureWorktree } from './worktree';
9
9
  import { parseArgs } from 'util';
10
10
  import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
11
11
  import { join, dirname } from 'path';
12
+ import pkg from '../package.json' with { type: 'json' };
12
13
 
13
14
  const HOME = process.env.HOME || process.env.USERPROFILE || '.';
14
15
  const MULTI_DIR = join(HOME, '.multi');
15
16
  const CONFIG_PATH = join(MULTI_DIR, 'config.json');
16
17
  const PID_PATH = join(MULTI_DIR, 'agent.pid');
18
+ const PORT_PATH = join(MULTI_DIR, 'agent.port');
17
19
  const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
18
20
  const SKILLS_DIR = join(MULTI_DIR, 'skills');
19
21
  const STOP_PATH = join(MULTI_DIR, 'stop.flag');
20
22
  const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
21
- const VERSION = '0.9.2';
23
+ const VERSION = (pkg as { version: string }).version;
22
24
 
23
25
  const COMMANDS = {
24
26
  setup: 'Register this device with a workspace',
@@ -28,6 +30,7 @@ const COMMANDS = {
28
30
  stop: 'Stop the running daemon',
29
31
  restart: 'Stop and relaunch the daemon in background',
30
32
  logs: 'View execution logs',
33
+ reset: 'Reset acpx session for an issue (--issue <id>)',
31
34
  } as const;
32
35
 
33
36
  type Command = keyof typeof COMMANDS;
@@ -62,6 +65,7 @@ async function main() {
62
65
  workspace: { type: 'string' },
63
66
  agent: { type: 'string' },
64
67
  api: { type: 'string' },
68
+ issue: { type: 'string' },
65
69
  },
66
70
  allowPositionals: true,
67
71
  strict: false,
@@ -105,6 +109,9 @@ async function main() {
105
109
  case 'logs':
106
110
  await cmdLogs();
107
111
  break;
112
+ case 'reset':
113
+ await cmdReset(args.values.issue);
114
+ break;
108
115
  default:
109
116
  console.error(`Unknown command: ${command}`);
110
117
  printHelp();
@@ -126,6 +133,7 @@ Commands:
126
133
  stop ${COMMANDS.stop}
127
134
  restart ${COMMANDS.restart}
128
135
  logs ${COMMANDS.logs}
136
+ reset ${COMMANDS.reset}
129
137
 
130
138
  Options:
131
139
  --name <name> Device name
@@ -261,26 +269,52 @@ async function cmdConnect(apiUrl: string, config: Config) {
261
269
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
262
270
 
263
271
  const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
272
+ // Keyed by task row id — unique per dispatch. Duplicate issue_id / agent_id still
273
+ // serialize via busyAgents/busyIssues filters.
264
274
  const running = new Map<string, RunEntry>();
265
275
 
276
+ function resolvePayloadIds(row: { payload: string; agent_id: string | null; issue_id: string | null }): { agent_id: string | null; issue_id: string | null } {
277
+ let agent_id = row.agent_id;
278
+ let issue_id = row.issue_id;
279
+ if (!agent_id || !issue_id) {
280
+ try {
281
+ const p = JSON.parse(row.payload) as any;
282
+ agent_id ??= p?.agent_id ?? null;
283
+ issue_id ??= p?.issue_id ?? null;
284
+ } catch {}
285
+ }
286
+ return { agent_id, issue_id };
287
+ }
288
+
266
289
  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;
290
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v): v is string => !!v);
291
+ const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v): v is string => !!v);
292
+ const clauses: string[] = [];
293
+ const binds: string[] = [];
294
+ if (busyAgents.length) {
295
+ clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`);
296
+ binds.push(...busyAgents);
297
+ }
298
+ if (busyIssues.length) {
299
+ clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => '?').join(',')}))`);
300
+ binds.push(...busyIssues);
301
+ }
302
+ const where = clauses.length ? `AND ${clauses.join(' AND ')}` : '';
303
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
304
+ return db.query(sql).get(...binds) as any;
274
305
  }
275
306
 
276
307
  function schedule() {
277
308
  while (running.size < MAX_DEVICE) {
278
309
  const row = pickNext();
279
310
  if (!row) return;
311
+ const ids = resolvePayloadIds(row);
312
+ // Defensive: if DB saw NULL agent/issue but payload has them, skip if busy.
313
+ if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id)) return;
314
+ if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id)) return;
280
315
  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);
316
+ const entry: RunEntry = { agentId: ids.agent_id || '', issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: '' };
317
+ running.set(row.id, entry);
284
318
  void (async () => {
285
319
  try {
286
320
  const task = JSON.parse(row.payload);
@@ -290,7 +324,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
290
324
  log(`task ${row.id} error: ${String(e)}`);
291
325
  db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
292
326
  } finally {
293
- running.delete(issueKey);
327
+ running.delete(row.id);
294
328
  queueMicrotask(() => schedule());
295
329
  }
296
330
  })();
@@ -328,22 +362,64 @@ async function cmdConnect(apiUrl: string, config: Config) {
328
362
  }
329
363
  })();
330
364
  }
365
+ if (url.pathname === '/reset_session' && req.method === 'POST') {
366
+ if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
367
+ return (async () => {
368
+ try {
369
+ const { issue_id, agent_type } = await req.json() as { issue_id: string; agent_type?: string };
370
+ if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
371
+
372
+ // Kill any running acpx child for this issue.
373
+ const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
374
+ for (const entry of entries) {
375
+ entry.stopped = true;
376
+ entry.stopReason = 'session reset';
377
+ try { entry.child?.kill('SIGTERM'); } catch {}
378
+ setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 3000);
379
+ }
380
+
381
+ // Close acpx session so next dispatch creates fresh. Only known acpx types.
382
+ const acpxTypes = agent_type ? [agent_type] : ['pi', 'codex', 'openclaw'];
383
+ const sessionName = `issue-${issue_id}`;
384
+ const cwd = process.env.HOME || process.cwd();
385
+ const results: { agent: string; exit: number; stderr: string }[] = [];
386
+ for (const t of acpxTypes) {
387
+ try {
388
+ const rm = Bun.spawn(['acpx', '--ttl', '0', '--cwd', cwd, t, 'sessions', 'close', sessionName], {
389
+ stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
390
+ });
391
+ const err = await new Response(rm.stderr as any).text();
392
+ const exit = await rm.exited;
393
+ results.push({ agent: t, exit, stderr: err.slice(0, 200) });
394
+ } catch (e) {
395
+ results.push({ agent: t, exit: -1, stderr: String(e).slice(0, 200) });
396
+ }
397
+ }
398
+ log(`🔄 reset_session ${issue_id} (killed=${entries.length}) ${results.map(r => `${r.agent}:${r.exit}`).join(' ')}`);
399
+ return Response.json({ ok: true, killed: entries.length, sessions: results });
400
+ } catch (e) {
401
+ return Response.json({ error: String(e) }, { status: 400 });
402
+ }
403
+ })();
404
+ }
331
405
  if (url.pathname === '/stop' && req.method === 'POST') {
332
406
  if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
333
407
  return (async () => {
334
408
  try {
335
409
  const { issue_id } = await req.json() as { issue_id: string };
336
410
  if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
337
- const entry = running.get(issue_id);
338
- if (!entry) {
411
+ const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
412
+ if (!entries.length) {
339
413
  db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
340
414
  await markStopped(apiUrl, issue_id, 'stopped before start');
341
415
  return Response.json({ ok: true, state: 'queued-cancelled' });
342
416
  }
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);
417
+ for (const entry of entries) {
418
+ entry.stopped = true;
419
+ entry.stopReason = 'user requested';
420
+ try { entry.child?.kill('SIGTERM'); } catch {}
421
+ setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
422
+ }
347
423
  return Response.json({ ok: true, state: 'running-signalled' });
348
424
  } catch (e) {
349
425
  return Response.json({ error: String(e) }, { status: 400 });
@@ -354,6 +430,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
354
430
  },
355
431
  });
356
432
  log(`🌐 Local server: http://127.0.0.1:${port}`);
433
+ try { writeFileSync(PORT_PATH, String(port)); } catch {}
357
434
 
358
435
  // Spawn cloudflared quick tunnel
359
436
  const cf = Bun.spawn(['cloudflared', 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
@@ -381,6 +458,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
381
458
  try { await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline', tunnel_url: null }); } catch {}
382
459
  if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
383
460
  if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
461
+ if (existsSync(PORT_PATH)) unlinkSync(PORT_PATH);
384
462
  db.close();
385
463
  log('👋 Disconnected');
386
464
  process.exit(0);
@@ -462,6 +540,7 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
462
540
 
463
541
  interface RunEntry {
464
542
  agentId: string;
543
+ issueId: string | null;
465
544
  startedAt: number;
466
545
  child: any | null;
467
546
  worktreePath: string;
@@ -1377,6 +1456,22 @@ async function cmdStop() {
1377
1456
  try { process.kill(pid, 'SIGTERM'); console.log(`Sent SIGTERM to ${pid}`); } catch {}
1378
1457
  }
1379
1458
 
1459
+ async function cmdReset(issueId?: string) {
1460
+ if (!issueId) { console.error('Usage: multi-agent reset --issue <issue_id>'); process.exit(2); }
1461
+ if (!existsSync(PORT_PATH)) { console.error('Daemon not running (no port file).'); process.exit(1); }
1462
+ const port = Number(readFileSync(PORT_PATH, 'utf8').trim());
1463
+ const config = loadConfig();
1464
+ if (!config.dispatchSecret) { console.error('No dispatchSecret in config — run `multi-agent setup` first.'); process.exit(1); }
1465
+ const res = await fetch(`http://127.0.0.1:${port}/reset_session`, {
1466
+ method: 'POST',
1467
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${config.dispatchSecret}` },
1468
+ body: JSON.stringify({ issue_id: issueId }),
1469
+ });
1470
+ const body = await res.text();
1471
+ if (!res.ok) { console.error(`Reset failed (${res.status}): ${body}`); process.exit(1); }
1472
+ console.log(body);
1473
+ }
1474
+
1380
1475
  async function cmdRestart(apiUrl: string) {
1381
1476
  if (existsSync(PID_PATH)) {
1382
1477
  const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
@@ -1458,6 +1553,11 @@ function openTasksDb(): Database {
1458
1553
  `);
1459
1554
  // Old rows used 'pending'; normalize to 'queued'.
1460
1555
  db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
1556
+ // Backfill agent_id/issue_id from payload JSON for rows predating the columns.
1557
+ // Without this, the scheduler cannot serialize per-agent / per-issue and fires
1558
+ // concurrent dispatches for the same agent on the same issue.
1559
+ 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)");
1560
+ 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)");
1461
1561
  return db;
1462
1562
  }
1463
1563