@shipers-dev/multi 0.8.3 → 0.9.0

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
@@ -5185,6 +5185,9 @@ async function runAcp(opts) {
5185
5185
  cwd: opts.cwd || process.cwd(),
5186
5186
  env: { ...cleanEnv, ACP_PERMISSION_MODE: permMode }
5187
5187
  });
5188
+ try {
5189
+ opts.onSpawn?.(child);
5190
+ } catch {}
5188
5191
  const output = new WritableStream({
5189
5192
  write(chunk) {
5190
5193
  child.stdin.write(chunk);
@@ -5449,6 +5452,9 @@ async function runAcpx(opts) {
5449
5452
  args.push(opts.prompt);
5450
5453
  dlog(`[acpx] prompt: ${args.slice(0, 10).join(" ")} ... (prompt len=${opts.prompt.length})`);
5451
5454
  const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
5455
+ try {
5456
+ opts.onSpawn?.(proc);
5457
+ } catch {}
5452
5458
  let stopReason = "end_turn";
5453
5459
  (async () => {
5454
5460
  try {
@@ -5595,18 +5601,94 @@ function extractText2(content) {
5595
5601
  return "";
5596
5602
  }
5597
5603
 
5604
+ // src/worktree.ts
5605
+ import { spawn } from "child_process";
5606
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
5607
+ import { join as join2 } from "path";
5608
+ async function run(cwd, cmd, args) {
5609
+ return await new Promise((resolve) => {
5610
+ const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
5611
+ let stdout = "";
5612
+ let stderr = "";
5613
+ p.stdout.on("data", (d) => {
5614
+ stdout += d.toString();
5615
+ });
5616
+ p.stderr.on("data", (d) => {
5617
+ stderr += d.toString();
5618
+ });
5619
+ p.on("close", (code) => resolve({ code: code ?? 0, stdout: stdout.trim(), stderr: stderr.trim() }));
5620
+ p.on("error", (e) => resolve({ code: 1, stdout: "", stderr: String(e) }));
5621
+ });
5622
+ }
5623
+ async function isGitRepo(dir) {
5624
+ if (!existsSync2(dir))
5625
+ return false;
5626
+ const r = await run(dir, "git", ["rev-parse", "--is-inside-work-tree"]);
5627
+ return r.code === 0 && r.stdout === "true";
5628
+ }
5629
+ async function branchExists(dir, branch) {
5630
+ const r = await run(dir, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
5631
+ return r.code === 0;
5632
+ }
5633
+ function ensureGitignoreEntry(workingDir, entry) {
5634
+ const gip = join2(workingDir, ".gitignore");
5635
+ let body = "";
5636
+ try {
5637
+ body = existsSync2(gip) ? readFileSync2(gip, "utf8") : "";
5638
+ } catch {
5639
+ body = "";
5640
+ }
5641
+ const lines = body.split(`
5642
+ `);
5643
+ if (lines.some((l) => l.trim() === entry))
5644
+ return;
5645
+ const nextBody = (body.endsWith(`
5646
+ `) || body === "" ? body : body + `
5647
+ `) + entry + `
5648
+ `;
5649
+ try {
5650
+ writeFileSync2(gip, nextBody, "utf8");
5651
+ } catch {}
5652
+ }
5653
+ function normalizeKey(issueKey) {
5654
+ return issueKey.toLowerCase().replace(/[^a-z0-9\-_\/]/g, "-");
5655
+ }
5656
+ async function ensureWorktree(workingDir, issueKey) {
5657
+ if (!await isGitRepo(workingDir)) {
5658
+ return { path: workingDir, branch: "", created: false };
5659
+ }
5660
+ ensureGitignoreEntry(workingDir, ".multi/");
5661
+ const key = normalizeKey(issueKey);
5662
+ const branch = `multi/${key}`;
5663
+ const wtDir = join2(workingDir, ".multi", "worktrees");
5664
+ const wtPath = join2(wtDir, key);
5665
+ if (existsSync2(wtPath)) {
5666
+ return { path: wtPath, branch, created: false };
5667
+ }
5668
+ try {
5669
+ mkdirSync2(wtDir, { recursive: true });
5670
+ } catch {}
5671
+ const exists = await branchExists(workingDir, branch);
5672
+ const args = exists ? ["worktree", "add", wtPath, branch] : ["worktree", "add", "-b", branch, wtPath, "HEAD"];
5673
+ const r = await run(workingDir, "git", args);
5674
+ if (r.code !== 0) {
5675
+ throw new Error(`git worktree add failed: ${r.stderr || r.stdout}`);
5676
+ }
5677
+ return { path: wtPath, branch, created: true };
5678
+ }
5679
+
5598
5680
  // src/index.ts
5599
5681
  import { parseArgs } from "util";
5600
- import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
5601
- import { join as join2, dirname as dirname2 } from "path";
5682
+ import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
5683
+ import { join as join3, dirname as dirname2 } from "path";
5602
5684
  var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
5603
- var MULTI_DIR = join2(HOME2, ".multi");
5604
- var CONFIG_PATH = join2(MULTI_DIR, "config.json");
5605
- var PID_PATH = join2(MULTI_DIR, "agent.pid");
5606
- var LOG_PATH2 = join2(MULTI_DIR, "logs", "agent.log");
5607
- var SKILLS_DIR = join2(MULTI_DIR, "skills");
5608
- var STOP_PATH = join2(MULTI_DIR, "stop.flag");
5609
- var TASKS_DB_PATH = join2(MULTI_DIR, "tasks.db");
5685
+ var MULTI_DIR = join3(HOME2, ".multi");
5686
+ var CONFIG_PATH = join3(MULTI_DIR, "config.json");
5687
+ var PID_PATH = join3(MULTI_DIR, "agent.pid");
5688
+ var LOG_PATH2 = join3(MULTI_DIR, "logs", "agent.log");
5689
+ var SKILLS_DIR = join3(MULTI_DIR, "skills");
5690
+ var STOP_PATH = join3(MULTI_DIR, "stop.flag");
5691
+ var TASKS_DB_PATH = join3(MULTI_DIR, "tasks.db");
5610
5692
  var VERSION = "0.8.0";
5611
5693
  var COMMANDS = {
5612
5694
  setup: "Register this device with a workspace",
@@ -5617,9 +5699,9 @@ var COMMANDS = {
5617
5699
  logs: "View execution logs"
5618
5700
  };
5619
5701
  function ensureDirs() {
5620
- for (const d of [MULTI_DIR, join2(MULTI_DIR, "logs"), SKILLS_DIR]) {
5621
- if (!existsSync2(d))
5622
- mkdirSync2(d, { recursive: true });
5702
+ for (const d of [MULTI_DIR, join3(MULTI_DIR, "logs"), SKILLS_DIR]) {
5703
+ if (!existsSync3(d))
5704
+ mkdirSync3(d, { recursive: true });
5623
5705
  }
5624
5706
  }
5625
5707
  function log(msg) {
@@ -5792,7 +5874,7 @@ async function syncSkills(apiUrl, workspaceId) {
5792
5874
  return;
5793
5875
  ensureDirs();
5794
5876
  for (const skill of res.data) {
5795
- writeFileSync2(join2(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
5877
+ writeFileSync3(join3(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
5796
5878
  }
5797
5879
  if (res.data.length)
5798
5880
  console.log(` Synced ${res.data.length} skill(s) \u2192 ${SKILLS_DIR}`);
@@ -5822,8 +5904,8 @@ async function cmdConnect(apiUrl, config) {
5822
5904
  console.log('\u274C Missing dispatch secret. Re-pair via "multi-agent setup".');
5823
5905
  process.exit(1);
5824
5906
  }
5825
- if (existsSync2(PID_PATH)) {
5826
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
5907
+ if (existsSync3(PID_PATH)) {
5908
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
5827
5909
  if (pid && isRunning(pid)) {
5828
5910
  console.log(`\u274C Daemon already running (pid ${pid}).`);
5829
5911
  process.exit(1);
@@ -5831,21 +5913,46 @@ async function cmdConnect(apiUrl, config) {
5831
5913
  unlinkSync(PID_PATH);
5832
5914
  }
5833
5915
  ensureDirs();
5834
- writeFileSync2(PID_PATH, String(process.pid));
5835
- if (existsSync2(STOP_PATH))
5916
+ writeFileSync3(PID_PATH, String(process.pid));
5917
+ if (existsSync3(STOP_PATH))
5836
5918
  unlinkSync(STOP_PATH);
5837
5919
  const detected = await detectAgents();
5838
5920
  log(`\uD83D\uDE80 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
5839
5921
  log(` runtimes: ${detected.map((d) => d.type).join(", ") || "stub"}`);
5840
5922
  const db = openTasksDb();
5841
- db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
5842
- let workerWake = null;
5843
- const notifyWorker = () => {
5844
- try {
5845
- workerWake?.();
5846
- workerWake = null;
5847
- } catch {}
5848
- };
5923
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
5924
+ const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
5925
+ const running = new Map;
5926
+ function pickNext() {
5927
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean);
5928
+ const notIn = busyAgents.length ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))` : "";
5929
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
5930
+ return db.query(sql).get(...busyAgents);
5931
+ }
5932
+ function schedule() {
5933
+ while (running.size < MAX_DEVICE) {
5934
+ const row = pickNext();
5935
+ if (!row)
5936
+ return;
5937
+ db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5938
+ const entry = { agentId: row.agent_id || "", startedAt: Date.now(), child: null, worktreePath: "" };
5939
+ const issueKey = row.issue_id || row.id;
5940
+ running.set(issueKey, entry);
5941
+ (async () => {
5942
+ try {
5943
+ const task = JSON.parse(row.payload);
5944
+ await handleRunTask(apiUrl, config.deviceId, task, detected, { runEntry: entry });
5945
+ db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5946
+ } catch (e) {
5947
+ log(`task ${row.id} error: ${String(e)}`);
5948
+ db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5949
+ } finally {
5950
+ running.delete(issueKey);
5951
+ queueMicrotask(() => schedule());
5952
+ }
5953
+ })();
5954
+ }
5955
+ }
5849
5956
  const port = await pickFreePort();
5850
5957
  const expectedAuth = `Bearer ${config.dispatchSecret}`;
5851
5958
  const server = Bun.serve({
@@ -5861,15 +5968,50 @@ async function cmdConnect(apiUrl, config) {
5861
5968
  return (async () => {
5862
5969
  try {
5863
5970
  const body = await req.json();
5864
- const taskId = body?.task?.issue_id ? `${body.task.issue_id}-${Date.now()}` : crypto.randomUUID();
5865
- db.run("INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)", [taskId, "pending", JSON.stringify(body.task)]);
5866
- notifyWorker();
5971
+ const t = body?.task || {};
5972
+ const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
5973
+ db.run("INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)", [taskId, JSON.stringify(t), t.agent_id ?? null, t.issue_id ?? null]);
5974
+ const pos = db.query("SELECT COUNT(*) AS c FROM tasks WHERE status = 'queued' AND created_at <= (SELECT created_at FROM tasks WHERE id = ?)").get(taskId)?.c ?? 1;
5975
+ if (t.issue_id) {
5976
+ postStream(apiUrl, t.issue_id, "queued", { queue_position: pos });
5977
+ }
5978
+ queueMicrotask(() => schedule());
5867
5979
  return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
5868
5980
  } catch (e) {
5869
5981
  return Response.json({ error: String(e) }, { status: 400 });
5870
5982
  }
5871
5983
  })();
5872
5984
  }
5985
+ if (url.pathname === "/stop" && req.method === "POST") {
5986
+ if (req.headers.get("authorization") !== expectedAuth)
5987
+ return new Response("unauthorized", { status: 401 });
5988
+ return (async () => {
5989
+ try {
5990
+ const { issue_id } = await req.json();
5991
+ if (!issue_id)
5992
+ return Response.json({ error: "issue_id required" }, { status: 400 });
5993
+ const entry = running.get(issue_id);
5994
+ if (!entry) {
5995
+ db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
5996
+ await markStopped(apiUrl, issue_id, "stopped before start");
5997
+ return Response.json({ ok: true, state: "queued-cancelled" });
5998
+ }
5999
+ entry.stopped = true;
6000
+ entry.stopReason = "user requested";
6001
+ try {
6002
+ entry.child?.kill("SIGTERM");
6003
+ } catch {}
6004
+ setTimeout(() => {
6005
+ try {
6006
+ entry.child?.kill("SIGKILL");
6007
+ } catch {}
6008
+ }, 5000);
6009
+ return Response.json({ ok: true, state: "running-signalled" });
6010
+ } catch (e) {
6011
+ return Response.json({ error: String(e) }, { status: 400 });
6012
+ }
6013
+ })();
6014
+ }
5873
6015
  return new Response("not found", { status: 404 });
5874
6016
  }
5875
6017
  });
@@ -5892,11 +6034,11 @@ async function cmdConnect(apiUrl, config) {
5892
6034
  }
5893
6035
  log(`\u2601\uFE0F Tunnel up: ${tunnelUrl}`);
5894
6036
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
5895
- let running = true;
6037
+ let alive = true;
5896
6038
  const shutdown = async (reason) => {
5897
- if (!running)
6039
+ if (!alive)
5898
6040
  return;
5899
- running = false;
6041
+ alive = false;
5900
6042
  log(`\uD83D\uDED1 Shutting down (${reason})`);
5901
6043
  try {
5902
6044
  server.stop();
@@ -5907,9 +6049,9 @@ async function cmdConnect(apiUrl, config) {
5907
6049
  try {
5908
6050
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
5909
6051
  } catch {}
5910
- if (existsSync2(PID_PATH))
6052
+ if (existsSync3(PID_PATH))
5911
6053
  unlinkSync(PID_PATH);
5912
- if (existsSync2(STOP_PATH))
6054
+ if (existsSync3(STOP_PATH))
5913
6055
  unlinkSync(STOP_PATH);
5914
6056
  db.close();
5915
6057
  log("\uD83D\uDC4B Disconnected");
@@ -5917,30 +6059,10 @@ async function cmdConnect(apiUrl, config) {
5917
6059
  };
5918
6060
  process.on("SIGINT", () => shutdown("SIGINT"));
5919
6061
  process.on("SIGTERM", () => shutdown("SIGTERM"));
5920
- (async () => {
5921
- while (running) {
5922
- const row = db.query("SELECT id, payload FROM tasks WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1").get();
5923
- if (!row) {
5924
- await new Promise((resolve) => {
5925
- workerWake = resolve;
5926
- setTimeout(resolve, 5000);
5927
- });
5928
- continue;
5929
- }
5930
- db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5931
- try {
5932
- const task = JSON.parse(row.payload);
5933
- await handleRunTask(apiUrl, config.deviceId, task, detected, {});
5934
- db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5935
- } catch (e) {
5936
- log(`task ${row.id} error: ${String(e)}`);
5937
- db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5938
- }
5939
- }
5940
- })();
5941
- while (running) {
6062
+ schedule();
6063
+ while (alive) {
5942
6064
  await sleep(20000);
5943
- if (existsSync2(STOP_PATH)) {
6065
+ if (existsSync3(STOP_PATH)) {
5944
6066
  await shutdown("stop flag");
5945
6067
  break;
5946
6068
  }
@@ -5952,8 +6074,8 @@ async function cmdConnect(apiUrl, config) {
5952
6074
  }
5953
6075
  }
5954
6076
  async function cmdConnectDetached(apiUrl) {
5955
- if (existsSync2(PID_PATH)) {
5956
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
6077
+ if (existsSync3(PID_PATH)) {
6078
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
5957
6079
  if (pid && isRunning(pid)) {
5958
6080
  console.log(`\u274C Daemon already running (pid ${pid}).`);
5959
6081
  process.exit(1);
@@ -6010,22 +6132,48 @@ async function parseTunnelUrl(stream2) {
6010
6132
  }
6011
6133
  return null;
6012
6134
  }
6135
+ async function markStopped(apiUrl, issueId, reason) {
6136
+ try {
6137
+ await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "stopped" });
6138
+ } catch {}
6139
+ try {
6140
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
6141
+ author_type: "agent",
6142
+ author_id: "daemon",
6143
+ author_name: "daemon",
6144
+ body: `\u23F9 Stopped: ${reason}`
6145
+ });
6146
+ } catch {}
6147
+ await postStream(apiUrl, issueId, "stopped", { reason });
6148
+ }
6013
6149
  async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6014
6150
  const issueId = task.issue_id;
6015
6151
  const isFollowup = !!task.followup;
6016
- const workingDir = task.working_dir && existsSync2(task.working_dir) ? task.working_dir : undefined;
6017
- log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}]` : ""}`);
6152
+ const baseWorkingDir = task.working_dir && existsSync3(task.working_dir) ? task.working_dir : undefined;
6153
+ let workingDir = baseWorkingDir;
6154
+ let worktreeBranch = "";
6155
+ if (baseWorkingDir) {
6156
+ try {
6157
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
6158
+ workingDir = wt.path;
6159
+ worktreeBranch = wt.branch;
6160
+ await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
6161
+ } catch (e) {
6162
+ await postStream(apiUrl, issueId, "worktree_error", { message: String(e) });
6163
+ }
6164
+ }
6165
+ log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ""}]` : ""}`);
6018
6166
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "in_progress" });
6019
6167
  await postStream(apiUrl, issueId, "progress", { message: `Device ${deviceId} picked up ${isFollowup ? "follow-up" : "task"}` });
6020
6168
  let attachmentRefs = [];
6021
6169
  if (task.from_comment_id) {
6022
- const baseDir = workingDir || join2(MULTI_DIR, "tmp", issueId);
6023
- const inDir = join2(baseDir, ".multi-in", task.from_comment_id);
6170
+ const baseDir = workingDir || join3(MULTI_DIR, "tmp", issueId);
6171
+ const inDir = join3(baseDir, ".multi-in", task.from_comment_id);
6024
6172
  attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
6025
6173
  if (attachmentRefs.length)
6026
6174
  log(` fetched ${attachmentRefs.length} attachment(s) \u2192 ${inDir}`);
6027
6175
  }
6028
- const outDir = join2(workingDir || join2(MULTI_DIR, "tmp", issueId), ".multi-out");
6176
+ const outDir = join3(workingDir || join3(MULTI_DIR, "tmp", issueId), ".multi-out");
6029
6177
  let liveCommentId;
6030
6178
  let liveBody = "";
6031
6179
  let hadError = false;
@@ -6333,6 +6481,7 @@ ${userPart}` : userPart;
6333
6481
  if (!adapterBin)
6334
6482
  throw new Error(`ACP adapter for ${chosen.type} not found`);
6335
6483
  log(` adapter: ${chosen.type} \u2192 ${adapterBin.join(" ")}`);
6484
+ const startedAt = Date.now();
6336
6485
  const { sessionId } = await runAcp({
6337
6486
  apiUrl,
6338
6487
  issueId,
@@ -6347,8 +6496,16 @@ ${userPart}` : userPart;
6347
6496
  try {
6348
6497
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid });
6349
6498
  } catch {}
6499
+ },
6500
+ onSpawn: (child) => {
6501
+ if (ctx?.runEntry) {
6502
+ ctx.runEntry.child = child;
6503
+ ctx.runEntry.worktreePath = workingDir || "";
6504
+ }
6505
+ postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
6350
6506
  }
6351
6507
  });
6508
+ postStream(apiUrl, issueId, "run_finished", { stopReason: typeof sessionId === "string" ? "ok" : "unknown", duration_ms: Date.now() - startedAt });
6352
6509
  log(` acp session ${sessionId.slice(0, 8)}`);
6353
6510
  } else if (useAcpx) {
6354
6511
  let preamble = "";
@@ -6416,13 +6573,22 @@ Write generated files to: ${outDir}`;
6416
6573
 
6417
6574
  ${userPart}` : userPart;
6418
6575
  log(` acpx runner: ${preferType}`);
6576
+ const acpxStartedAt = Date.now();
6419
6577
  await runAcpx({
6420
6578
  agentType: preferType,
6421
6579
  prompt: full,
6422
6580
  cwd: workingDir,
6423
6581
  sessionName: `issue-${issueId}`,
6424
- onEvent: eventHandler
6582
+ onEvent: eventHandler,
6583
+ onSpawn: (child) => {
6584
+ if (ctx?.runEntry) {
6585
+ ctx.runEntry.child = child;
6586
+ ctx.runEntry.worktreePath = workingDir || "";
6587
+ }
6588
+ postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
6589
+ }
6425
6590
  });
6591
+ postStream(apiUrl, issueId, "run_finished", { stopReason: "ok", duration_ms: Date.now() - acpxStartedAt });
6426
6592
  } else {
6427
6593
  const runner = pickRunner(detected, preferType);
6428
6594
  for await (const event of runner(task))
@@ -6451,7 +6617,10 @@ ${userPart}` : userPart;
6451
6617
  await postComment(`\u26A0\uFE0F Agent returned no output (stopReason=${stopReason}). Adapter may be stuck on a stale session \u2014 try starting a new issue or clearing session_id.`);
6452
6618
  log(` \u26A0 ${task.key} produced no assistant output (stopReason=${stopReason})`);
6453
6619
  }
6454
- if (hadError) {
6620
+ if (ctx?.runEntry?.stopped) {
6621
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
6622
+ log(` \u23F9 ${task.key} stopped`);
6623
+ } else if (hadError) {
6455
6624
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6456
6625
  log(` \u2717 ${task.key} failed`);
6457
6626
  } else {
@@ -6459,10 +6628,15 @@ ${userPart}` : userPart;
6459
6628
  log(` \u2713 ${task.key} complete`);
6460
6629
  }
6461
6630
  } catch (e) {
6462
- await postStream(apiUrl, issueId, "error", { message: String(e) });
6463
- await postComment(`\u274C spawn error: ${String(e)}`);
6464
- await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6465
- log(` \u2717 ${task.key} failed: ${String(e)}`);
6631
+ if (ctx?.runEntry?.stopped) {
6632
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
6633
+ log(` \u23F9 ${task.key} stopped (${String(e)})`);
6634
+ } else {
6635
+ await postStream(apiUrl, issueId, "error", { message: String(e) });
6636
+ await postComment(`\u274C spawn error: ${String(e)}`);
6637
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6638
+ log(` \u2717 ${task.key} failed: ${String(e)}`);
6639
+ }
6466
6640
  }
6467
6641
  }
6468
6642
  async function buildPlanningPreamble(apiUrl, task) {
@@ -6634,29 +6808,36 @@ function statusIcon(status) {
6634
6808
  }
6635
6809
  }
6636
6810
  async function resolveAcpAdapter(agentType, detectedPath) {
6637
- if (agentType === "pi" && detectedPath && existsSync2(detectedPath)) {
6811
+ if (agentType === "pi" && detectedPath && existsSync3(detectedPath)) {
6638
6812
  return [detectedPath, "--mode", "rpc"];
6639
6813
  }
6640
6814
  const adapterName = "claude-code-acp";
6641
6815
  const candidates = [
6642
- join2(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
6816
+ join3(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
6643
6817
  ];
6644
6818
  try {
6645
6819
  const here = new URL(import.meta.url).pathname;
6646
6820
  let dir = here;
6647
6821
  for (let i = 0;i < 8; i++) {
6648
6822
  dir = dirname2(dir);
6649
- const bin = join2(dir, "node_modules", ".bin", adapterName);
6650
- if (existsSync2(bin))
6823
+ const bin = join3(dir, "node_modules", ".bin", adapterName);
6824
+ if (existsSync3(bin))
6651
6825
  return [bin];
6652
6826
  }
6653
6827
  } catch {}
6654
6828
  for (const c of candidates)
6655
- if (existsSync2(c))
6829
+ if (existsSync3(c))
6656
6830
  return [c];
6657
6831
  return null;
6658
6832
  }
6659
6833
  async function postStream(apiUrl, issueId, event_type, payload) {
6834
+ try {
6835
+ ensureDirs();
6836
+ const date = new Date().toISOString().slice(0, 10);
6837
+ const path = join3(MULTI_DIR, "logs", `events-${date}.ndjson`);
6838
+ appendFileSync2(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + `
6839
+ `);
6840
+ } catch {}
6660
6841
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
6661
6842
  }
6662
6843
  async function downloadCommentAttachments(apiUrl, commentId, destDir) {
@@ -6665,7 +6846,7 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
6665
6846
  const items = list.data?.results || list.data || [];
6666
6847
  if (!Array.isArray(items) || items.length === 0)
6667
6848
  return [];
6668
- mkdirSync2(destDir, { recursive: true });
6849
+ mkdirSync3(destDir, { recursive: true });
6669
6850
  const token = authTokenHeader();
6670
6851
  const out = [];
6671
6852
  for (const it of items) {
@@ -6674,8 +6855,8 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
6674
6855
  continue;
6675
6856
  const buf = new Uint8Array(await res.arrayBuffer());
6676
6857
  const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
6677
- const p = join2(destDir, safe);
6678
- writeFileSync2(p, buf);
6858
+ const p = join3(destDir, safe);
6859
+ writeFileSync3(p, buf);
6679
6860
  out.push({ filename: it.filename, path: p });
6680
6861
  }
6681
6862
  return out;
@@ -6688,14 +6869,14 @@ function authTokenHeader() {
6688
6869
  return cfg.token ? `Bearer ${cfg.token}` : null;
6689
6870
  }
6690
6871
  async function uploadOutputDir(apiUrl, commentId, dir) {
6691
- if (!existsSync2(dir))
6872
+ if (!existsSync3(dir))
6692
6873
  return 0;
6693
6874
  const files = [];
6694
6875
  const walk = (d, depth = 0) => {
6695
6876
  if (depth > 3)
6696
6877
  return;
6697
6878
  for (const name of readdirSync(d)) {
6698
- const p = join2(d, name);
6879
+ const p = join3(d, name);
6699
6880
  try {
6700
6881
  const st = statSync(p);
6701
6882
  if (st.isDirectory())
@@ -6712,7 +6893,7 @@ async function uploadOutputDir(apiUrl, commentId, dir) {
6712
6893
  let uploaded = 0;
6713
6894
  for (const f of files) {
6714
6895
  try {
6715
- const data = readFileSync2(f);
6896
+ const data = readFileSync3(f);
6716
6897
  const form = new FormData;
6717
6898
  const blob = new Blob([data]);
6718
6899
  form.append("file", blob, f.split("/").pop() || "file");
@@ -6939,7 +7120,7 @@ async function cmdStatus(apiUrl, config) {
6939
7120
  process.exit(1);
6940
7121
  }
6941
7122
  const d = res.data;
6942
- const pid = existsSync2(PID_PATH) ? readFileSync2(PID_PATH, "utf8").trim() : null;
7123
+ const pid = existsSync3(PID_PATH) ? readFileSync3(PID_PATH, "utf8").trim() : null;
6943
7124
  const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : "stopped";
6944
7125
  console.log(`
6945
7126
  Device Status
@@ -6953,29 +7134,29 @@ Daemon: ${daemon}
6953
7134
  `);
6954
7135
  }
6955
7136
  async function cmdStop() {
6956
- if (!existsSync2(PID_PATH)) {
7137
+ if (!existsSync3(PID_PATH)) {
6957
7138
  console.log("No daemon running.");
6958
7139
  return;
6959
7140
  }
6960
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
7141
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
6961
7142
  if (!pid || !isRunning(pid)) {
6962
7143
  unlinkSync(PID_PATH);
6963
7144
  console.log("Cleaned stale pidfile.");
6964
7145
  return;
6965
7146
  }
6966
7147
  ensureDirs();
6967
- writeFileSync2(STOP_PATH, "1");
7148
+ writeFileSync3(STOP_PATH, "1");
6968
7149
  try {
6969
7150
  process.kill(pid, "SIGTERM");
6970
7151
  console.log(`Sent SIGTERM to ${pid}`);
6971
7152
  } catch {}
6972
7153
  }
6973
7154
  async function cmdLogs() {
6974
- if (!existsSync2(LOG_PATH2)) {
7155
+ if (!existsSync3(LOG_PATH2)) {
6975
7156
  console.log("No logs yet.");
6976
7157
  return;
6977
7158
  }
6978
- const content = readFileSync2(LOG_PATH2, "utf8");
7159
+ const content = readFileSync3(LOG_PATH2, "utf8");
6979
7160
  console.log(content.split(`
6980
7161
  `).slice(-100).join(`
6981
7162
  `));
@@ -6994,7 +7175,7 @@ function openTasksDb() {
6994
7175
  db.exec(`
6995
7176
  CREATE TABLE IF NOT EXISTS tasks (
6996
7177
  id TEXT PRIMARY KEY,
6997
- status TEXT NOT NULL DEFAULT 'pending',
7178
+ status TEXT NOT NULL DEFAULT 'queued',
6998
7179
  payload TEXT NOT NULL,
6999
7180
  attempts INTEGER NOT NULL DEFAULT 0,
7000
7181
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
@@ -7004,20 +7185,31 @@ function openTasksDb() {
7004
7185
  );
7005
7186
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
7006
7187
  `);
7188
+ const cols = db.query("PRAGMA table_info(tasks)").all();
7189
+ const have = new Set(cols.map((c) => c.name));
7190
+ if (!have.has("agent_id"))
7191
+ db.exec("ALTER TABLE tasks ADD COLUMN agent_id TEXT");
7192
+ if (!have.has("issue_id"))
7193
+ db.exec("ALTER TABLE tasks ADD COLUMN issue_id TEXT");
7194
+ db.exec(`
7195
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
7196
+ CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
7197
+ `);
7198
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
7007
7199
  return db;
7008
7200
  }
7009
7201
  function loadConfig() {
7010
7202
  try {
7011
- if (!existsSync2(CONFIG_PATH))
7203
+ if (!existsSync3(CONFIG_PATH))
7012
7204
  return {};
7013
- return JSON.parse(readFileSync2(CONFIG_PATH, "utf8"));
7205
+ return JSON.parse(readFileSync3(CONFIG_PATH, "utf8"));
7014
7206
  } catch {
7015
7207
  return {};
7016
7208
  }
7017
7209
  }
7018
7210
  function saveConfig(config) {
7019
7211
  ensureDirs();
7020
- writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2));
7212
+ writeFileSync3(CONFIG_PATH, JSON.stringify(config, null, 2));
7021
7213
  }
7022
7214
  function sleep(ms) {
7023
7215
  return new Promise((r) => setTimeout(r, ms));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/acp-runner.ts CHANGED
@@ -27,6 +27,7 @@ export type AcpRunOpts = {
27
27
  autonomy?: 'manual' | 'ask' | 'auto';
28
28
  onEvent: (ev: AcpEvent) => void | Promise<void>;
29
29
  onSession?: (sessionId: string) => void | Promise<void>;
30
+ onSpawn?: (child: any) => void;
30
31
  };
31
32
 
32
33
  export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; sessionId: string }> {
@@ -45,6 +46,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
45
46
  cwd: opts.cwd || process.cwd(),
46
47
  env: { ...cleanEnv, ACP_PERMISSION_MODE: permMode },
47
48
  });
49
+ try { opts.onSpawn?.(child); } catch {}
48
50
 
49
51
  // Adapter expects plain newline-delimited JSON on stdio. Wrap child streams as Web Streams.
50
52
  const output = new WritableStream<Uint8Array>({
@@ -21,6 +21,7 @@ export interface AcpxRunOpts {
21
21
  cwd?: string;
22
22
  sessionName?: string; // reuse same session across follow-ups (e.g. "issue-<id>")
23
23
  onEvent: (ev: AcpEvent) => void | Promise<void>;
24
+ onSpawn?: (child: any) => void;
24
25
  }
25
26
 
26
27
  export async function runAcpx(opts: AcpxRunOpts): Promise<{ stopReason: string }> {
@@ -49,6 +50,7 @@ export async function runAcpx(opts: AcpxRunOpts): Promise<{ stopReason: string }
49
50
 
50
51
  dlog(`[acpx] prompt: ${args.slice(0, 10).join(' ')} ... (prompt len=${opts.prompt.length})`);
51
52
  const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
53
+ try { opts.onSpawn?.(proc); } catch {}
52
54
  let stopReason = 'end_turn';
53
55
 
54
56
  // Forward stderr to our progress stream so we can debug in the UI/logs
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { apiClient, setAuthToken } from './client';
5
5
  import { Database } from 'bun:sqlite';
6
6
  import { runAcp } from './acp-runner';
7
7
  import { runAcpx } from './acpx-runner';
8
+ import { ensureWorktree } from './worktree';
8
9
  import { parseArgs } from 'util';
9
10
  import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
10
11
  import { join, dirname } from 'path';
@@ -252,10 +253,44 @@ async function cmdConnect(apiUrl: string, config: Config) {
252
253
  const db = openTasksDb();
253
254
 
254
255
  // Requeue orphaned 'running' tasks from previous crash
255
- db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
256
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
257
+
258
+ const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
259
+ const running = new Map<string, RunEntry>();
260
+
261
+ function pickNext(): { id: string; payload: string; agent_id: string | null; issue_id: string | null } | null {
262
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean) as string[];
263
+ // Emulate NOT IN (:list) via string construction (safe: only ids from DB).
264
+ const notIn = busyAgents.length
265
+ ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`
266
+ : '';
267
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
268
+ return db.query(sql).get(...busyAgents) as any;
269
+ }
256
270
 
257
- let workerWake: (() => void) | null = null;
258
- const notifyWorker = () => { try { workerWake?.(); workerWake = null; } catch {} };
271
+ function schedule() {
272
+ while (running.size < MAX_DEVICE) {
273
+ const row = pickNext();
274
+ if (!row) return;
275
+ db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
276
+ const entry: RunEntry = { agentId: row.agent_id || '', startedAt: Date.now(), child: null, worktreePath: '' };
277
+ const issueKey = row.issue_id || row.id;
278
+ running.set(issueKey, entry);
279
+ void (async () => {
280
+ try {
281
+ const task = JSON.parse(row.payload);
282
+ await handleRunTask(apiUrl, config.deviceId!, task, detected, { runEntry: entry });
283
+ db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
284
+ } catch (e) {
285
+ log(`task ${row.id} error: ${String(e)}`);
286
+ db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
287
+ } finally {
288
+ running.delete(issueKey);
289
+ queueMicrotask(() => schedule());
290
+ }
291
+ })();
292
+ }
293
+ }
259
294
 
260
295
 
261
296
  // Local HTTP server on a free port
@@ -271,15 +306,45 @@ async function cmdConnect(apiUrl: string, config: Config) {
271
306
  return (async () => {
272
307
  try {
273
308
  const body = await req.json() as { task: any };
274
- const taskId = body?.task?.issue_id ? `${body.task.issue_id}-${Date.now()}` : crypto.randomUUID();
275
- db.run('INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)', [taskId, 'pending', JSON.stringify(body.task)]);
276
- notifyWorker();
309
+ const t = body?.task || {};
310
+ const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
311
+ db.run(
312
+ "INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
313
+ [taskId, JSON.stringify(t), t.agent_id ?? null, t.issue_id ?? null],
314
+ );
315
+ const pos = (db.query("SELECT COUNT(*) AS c FROM tasks WHERE status = 'queued' AND created_at <= (SELECT created_at FROM tasks WHERE id = ?)").get(taskId) as any)?.c ?? 1;
316
+ if (t.issue_id) {
317
+ void postStream(apiUrl, t.issue_id, 'queued', { queue_position: pos });
318
+ }
319
+ queueMicrotask(() => schedule());
277
320
  return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
278
321
  } catch (e) {
279
322
  return Response.json({ error: String(e) }, { status: 400 });
280
323
  }
281
324
  })();
282
325
  }
326
+ if (url.pathname === '/stop' && req.method === 'POST') {
327
+ if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
328
+ return (async () => {
329
+ try {
330
+ const { issue_id } = await req.json() as { issue_id: string };
331
+ if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
332
+ const entry = running.get(issue_id);
333
+ if (!entry) {
334
+ db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
335
+ await markStopped(apiUrl, issue_id, 'stopped before start');
336
+ return Response.json({ ok: true, state: 'queued-cancelled' });
337
+ }
338
+ entry.stopped = true;
339
+ entry.stopReason = 'user requested';
340
+ try { entry.child?.kill('SIGTERM'); } catch {}
341
+ setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
342
+ return Response.json({ ok: true, state: 'running-signalled' });
343
+ } catch (e) {
344
+ return Response.json({ error: String(e) }, { status: 400 });
345
+ }
346
+ })();
347
+ }
283
348
  return new Response('not found', { status: 404 });
284
349
  },
285
350
  });
@@ -300,11 +365,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
300
365
 
301
366
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
302
367
 
303
- let running = true;
368
+ let alive = true;
304
369
 
305
370
  const shutdown = async (reason: string) => {
306
- if (!running) return;
307
- running = false;
371
+ if (!alive) return;
372
+ alive = false;
308
373
  log(`🛑 Shutting down (${reason})`);
309
374
  try { server.stop(); } catch {}
310
375
  try { cf.kill(); } catch {}
@@ -318,28 +383,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
318
383
  process.on('SIGINT', () => shutdown('SIGINT'));
319
384
  process.on('SIGTERM', () => shutdown('SIGTERM'));
320
385
 
321
- // Worker loop: drain pending tasks
322
- (async () => {
323
- while (running) {
324
- const row = db.query("SELECT id, payload FROM tasks WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1").get() as { id: string; payload: string } | null;
325
- if (!row) {
326
- await new Promise<void>(resolve => { workerWake = resolve; setTimeout(resolve, 5000); });
327
- continue;
328
- }
329
- db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
330
- try {
331
- const task = JSON.parse(row.payload);
332
- await handleRunTask(apiUrl, config.deviceId!, task, detected, {});
333
- db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
334
- } catch (e) {
335
- log(`task ${row.id} error: ${String(e)}`);
336
- db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
337
- }
338
- }
339
- })();
386
+ // Kick the scheduler on startup to drain any leftover queued rows.
387
+ schedule();
340
388
 
341
389
  // Heartbeat loop
342
- while (running) {
390
+ while (alive) {
343
391
  await sleep(20000);
344
392
  if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
345
393
  try {
@@ -407,15 +455,52 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
407
455
  return null;
408
456
  }
409
457
 
458
+ interface RunEntry {
459
+ agentId: string;
460
+ startedAt: number;
461
+ child: any | null;
462
+ worktreePath: string;
463
+ stopped?: boolean;
464
+ stopReason?: string;
465
+ }
466
+
410
467
  interface RuntimeCtx {
411
- // Reserved for future runtime-scoped helpers.
468
+ runEntry?: RunEntry;
469
+ refreshLocalAgents?: () => Promise<void>;
470
+ }
471
+
472
+ async function markStopped(apiUrl: string, issueId: string, reason: string) {
473
+ try {
474
+ await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'stopped' });
475
+ } catch {}
476
+ try {
477
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
478
+ author_type: 'agent', author_id: 'daemon', author_name: 'daemon', body: `⏹ Stopped: ${reason}`,
479
+ });
480
+ } catch {}
481
+ await postStream(apiUrl, issueId, 'stopped', { reason });
412
482
  }
413
483
 
414
484
  async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[], ctx?: RuntimeCtx) {
415
485
  const issueId = task.issue_id;
416
486
  const isFollowup = !!task.followup;
417
- const workingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
418
- log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}]` : ''}`);
487
+ const baseWorkingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
488
+
489
+ // Per-issue worktree isolation. Falls back to baseWorkingDir on failure or non-git.
490
+ let workingDir = baseWorkingDir;
491
+ let worktreeBranch = '';
492
+ if (baseWorkingDir) {
493
+ try {
494
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
495
+ workingDir = wt.path;
496
+ worktreeBranch = wt.branch;
497
+ await postStream(apiUrl, issueId, 'worktree_created', { path: wt.path, branch: wt.branch, reused: !wt.created });
498
+ } catch (e) {
499
+ await postStream(apiUrl, issueId, 'worktree_error', { message: String(e) });
500
+ }
501
+ }
502
+
503
+ log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ''}]` : ''}`);
419
504
 
420
505
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
421
506
  await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
@@ -678,6 +763,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
678
763
  if (!adapterBin) throw new Error(`ACP adapter for ${chosen.type} not found`);
679
764
  log(` adapter: ${chosen.type} → ${adapterBin.join(' ')}`);
680
765
 
766
+ const startedAt = Date.now();
681
767
  const { sessionId } = await runAcp({
682
768
  apiUrl, issueId, deviceId, prompt,
683
769
  sessionId: task.session_id || null,
@@ -688,7 +774,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
688
774
  onSession: async (sid) => {
689
775
  try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid }); } catch {}
690
776
  },
777
+ onSpawn: (child) => {
778
+ if (ctx?.runEntry) {
779
+ ctx.runEntry.child = child;
780
+ ctx.runEntry.worktreePath = workingDir || '';
781
+ }
782
+ void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
783
+ },
691
784
  });
785
+ void postStream(apiUrl, issueId, 'run_finished', { stopReason: (typeof sessionId === 'string' ? 'ok' : 'unknown'), duration_ms: Date.now() - startedAt });
692
786
  log(` acp session ${sessionId.slice(0, 8)}`);
693
787
  } else if (useAcpx) {
694
788
  // Build prompt with preamble (same logic as ACP path, but as one string)
@@ -724,13 +818,22 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
724
818
  }
725
819
  const full = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
726
820
  log(` acpx runner: ${preferType}`);
821
+ const acpxStartedAt = Date.now();
727
822
  await runAcpx({
728
823
  agentType: preferType!,
729
824
  prompt: full,
730
825
  cwd: workingDir,
731
826
  sessionName: `issue-${issueId}`,
732
827
  onEvent: eventHandler,
828
+ onSpawn: (child) => {
829
+ if (ctx?.runEntry) {
830
+ ctx.runEntry.child = child;
831
+ ctx.runEntry.worktreePath = workingDir || '';
832
+ }
833
+ void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
834
+ },
733
835
  });
836
+ void postStream(apiUrl, issueId, 'run_finished', { stopReason: 'ok', duration_ms: Date.now() - acpxStartedAt });
734
837
  } else {
735
838
  const runner = pickRunner(detected, preferType);
736
839
  for await (const event of runner(task)) await eventHandler(event);
@@ -761,13 +864,21 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
761
864
  log(` ⚠ ${task.key} produced no assistant output (stopReason=${stopReason})`);
762
865
  }
763
866
 
764
- if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
867
+ if (ctx?.runEntry?.stopped) {
868
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
869
+ log(` ⏹ ${task.key} stopped`);
870
+ } else if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
765
871
  else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
766
872
  } catch (e) {
767
- await postStream(apiUrl, issueId, 'error', { message: String(e) });
768
- await postComment(`❌ spawn error: ${String(e)}`);
769
- await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
770
- log(` ✗ ${task.key} failed: ${String(e)}`);
873
+ if (ctx?.runEntry?.stopped) {
874
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
875
+ log(` ⏹ ${task.key} stopped (${String(e)})`);
876
+ } else {
877
+ await postStream(apiUrl, issueId, 'error', { message: String(e) });
878
+ await postComment(`❌ spawn error: ${String(e)}`);
879
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
880
+ log(` ✗ ${task.key} failed: ${String(e)}`);
881
+ }
771
882
  }
772
883
  }
773
884
 
@@ -957,6 +1068,13 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
957
1068
  }
958
1069
 
959
1070
  async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
1071
+ // Local ndjson sink for tail -f debugging.
1072
+ try {
1073
+ ensureDirs();
1074
+ const date = new Date().toISOString().slice(0, 10);
1075
+ const path = join(MULTI_DIR, 'logs', `events-${date}.ndjson`);
1076
+ appendFileSync(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + '\n');
1077
+ } catch {}
960
1078
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
961
1079
  }
962
1080
 
@@ -1260,7 +1378,7 @@ function openTasksDb(): Database {
1260
1378
  db.exec(`
1261
1379
  CREATE TABLE IF NOT EXISTS tasks (
1262
1380
  id TEXT PRIMARY KEY,
1263
- status TEXT NOT NULL DEFAULT 'pending',
1381
+ status TEXT NOT NULL DEFAULT 'queued',
1264
1382
  payload TEXT NOT NULL,
1265
1383
  attempts INTEGER NOT NULL DEFAULT 0,
1266
1384
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
@@ -1270,6 +1388,17 @@ function openTasksDb(): Database {
1270
1388
  );
1271
1389
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
1272
1390
  `);
1391
+ // Idempotent migrations: add agent_id/issue_id columns if missing.
1392
+ const cols = db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
1393
+ const have = new Set(cols.map((c) => c.name));
1394
+ if (!have.has('agent_id')) db.exec('ALTER TABLE tasks ADD COLUMN agent_id TEXT');
1395
+ if (!have.has('issue_id')) db.exec('ALTER TABLE tasks ADD COLUMN issue_id TEXT');
1396
+ db.exec(`
1397
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
1398
+ CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
1399
+ `);
1400
+ // Old rows used 'pending'; normalize to 'queued'.
1401
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
1273
1402
  return db;
1274
1403
  }
1275
1404
 
@@ -0,0 +1,89 @@
1
+ // Per-issue git worktree manager. Keeps agent work isolated so parallel runs
2
+ // don't clobber each other. Worktrees live at <workingDir>/.multi/worktrees/<key>
3
+ // on branch multi/<key>, created off the current HEAD of workingDir.
4
+ //
5
+ // No automatic teardown: branch + worktree are kept after done/fail for human
6
+ // review.
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ export interface Worktree {
13
+ path: string;
14
+ branch: string;
15
+ created: boolean;
16
+ }
17
+
18
+ async function run(cwd: string, cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
19
+ return await new Promise((resolve) => {
20
+ const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ p.stdout.on('data', (d) => { stdout += d.toString(); });
24
+ p.stderr.on('data', (d) => { stderr += d.toString(); });
25
+ p.on('close', (code) => resolve({ code: code ?? 0, stdout: stdout.trim(), stderr: stderr.trim() }));
26
+ p.on('error', (e) => resolve({ code: 1, stdout: '', stderr: String(e) }));
27
+ });
28
+ }
29
+
30
+ export async function isGitRepo(dir: string): Promise<boolean> {
31
+ if (!existsSync(dir)) return false;
32
+ const r = await run(dir, 'git', ['rev-parse', '--is-inside-work-tree']);
33
+ return r.code === 0 && r.stdout === 'true';
34
+ }
35
+
36
+ async function branchExists(dir: string, branch: string): Promise<boolean> {
37
+ const r = await run(dir, 'git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
38
+ return r.code === 0;
39
+ }
40
+
41
+ function ensureGitignoreEntry(workingDir: string, entry: string): void {
42
+ const gip = join(workingDir, '.gitignore');
43
+ let body = '';
44
+ try { body = existsSync(gip) ? readFileSync(gip, 'utf8') : ''; } catch { body = ''; }
45
+ const lines = body.split('\n');
46
+ if (lines.some((l) => l.trim() === entry)) return;
47
+ const nextBody = (body.endsWith('\n') || body === '' ? body : body + '\n') + entry + '\n';
48
+ try { writeFileSync(gip, nextBody, 'utf8'); } catch {}
49
+ }
50
+
51
+ function normalizeKey(issueKey: string): string {
52
+ return issueKey.toLowerCase().replace(/[^a-z0-9\-_\/]/g, '-');
53
+ }
54
+
55
+ /**
56
+ * Ensure a worktree exists for this issue. Safe to call repeatedly.
57
+ *
58
+ * - Non-git workingDir: returns { path: workingDir, created: false } (no isolation).
59
+ * - Git workingDir: creates or reuses <workingDir>/.multi/worktrees/<key>
60
+ * on branch multi/<key>. On failure returns workingDir fallback.
61
+ */
62
+ export async function ensureWorktree(workingDir: string, issueKey: string): Promise<Worktree> {
63
+ if (!(await isGitRepo(workingDir))) {
64
+ return { path: workingDir, branch: '', created: false };
65
+ }
66
+
67
+ ensureGitignoreEntry(workingDir, '.multi/');
68
+
69
+ const key = normalizeKey(issueKey);
70
+ const branch = `multi/${key}`;
71
+ const wtDir = join(workingDir, '.multi', 'worktrees');
72
+ const wtPath = join(wtDir, key);
73
+
74
+ if (existsSync(wtPath)) {
75
+ return { path: wtPath, branch, created: false };
76
+ }
77
+
78
+ try { mkdirSync(wtDir, { recursive: true }); } catch {}
79
+
80
+ const exists = await branchExists(workingDir, branch);
81
+ const args = exists
82
+ ? ['worktree', 'add', wtPath, branch]
83
+ : ['worktree', 'add', '-b', branch, wtPath, 'HEAD'];
84
+ const r = await run(workingDir, 'git', args);
85
+ if (r.code !== 0) {
86
+ throw new Error(`git worktree add failed: ${r.stderr || r.stdout}`);
87
+ }
88
+ return { path: wtPath, branch, created: true };
89
+ }