@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 +282 -90
- package/package.json +1 -1
- package/src/acp-runner.ts +2 -0
- package/src/acpx-runner.ts +2 -0
- package/src/index.ts +167 -38
- package/src/worktree.ts +89 -0
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
|
|
5601
|
-
import { join as
|
|
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 =
|
|
5604
|
-
var CONFIG_PATH =
|
|
5605
|
-
var PID_PATH =
|
|
5606
|
-
var LOG_PATH2 =
|
|
5607
|
-
var SKILLS_DIR =
|
|
5608
|
-
var STOP_PATH =
|
|
5609
|
-
var TASKS_DB_PATH =
|
|
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,
|
|
5621
|
-
if (!
|
|
5622
|
-
|
|
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
|
-
|
|
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 (
|
|
5826
|
-
const pid = Number(
|
|
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
|
-
|
|
5835
|
-
if (
|
|
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 = '
|
|
5842
|
-
|
|
5843
|
-
const
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
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
|
|
5865
|
-
|
|
5866
|
-
|
|
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
|
|
6037
|
+
let alive = true;
|
|
5896
6038
|
const shutdown = async (reason) => {
|
|
5897
|
-
if (!
|
|
6039
|
+
if (!alive)
|
|
5898
6040
|
return;
|
|
5899
|
-
|
|
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 (
|
|
6052
|
+
if (existsSync3(PID_PATH))
|
|
5911
6053
|
unlinkSync(PID_PATH);
|
|
5912
|
-
if (
|
|
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
|
-
(
|
|
5921
|
-
|
|
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 (
|
|
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 (
|
|
5956
|
-
const pid = Number(
|
|
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
|
|
6017
|
-
|
|
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 ||
|
|
6023
|
-
const inDir =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 =
|
|
6650
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
6678
|
-
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
7137
|
+
if (!existsSync3(PID_PATH)) {
|
|
6957
7138
|
console.log("No daemon running.");
|
|
6958
7139
|
return;
|
|
6959
7140
|
}
|
|
6960
|
-
const pid = Number(
|
|
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
|
-
|
|
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 (!
|
|
7155
|
+
if (!existsSync3(LOG_PATH2)) {
|
|
6975
7156
|
console.log("No logs yet.");
|
|
6976
7157
|
return;
|
|
6977
7158
|
}
|
|
6978
|
-
const content =
|
|
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 '
|
|
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 (!
|
|
7203
|
+
if (!existsSync3(CONFIG_PATH))
|
|
7012
7204
|
return {};
|
|
7013
|
-
return JSON.parse(
|
|
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
|
-
|
|
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
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>({
|
package/src/acpx-runner.ts
CHANGED
|
@@ -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 = '
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
368
|
+
let alive = true;
|
|
304
369
|
|
|
305
370
|
const shutdown = async (reason: string) => {
|
|
306
|
-
if (!
|
|
307
|
-
|
|
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
|
-
//
|
|
322
|
-
(
|
|
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 (
|
|
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
|
-
|
|
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
|
|
418
|
-
|
|
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 (
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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 '
|
|
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
|
|
package/src/worktree.ts
ADDED
|
@@ -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
|
+
}
|