@shipers-dev/multi 0.8.3 → 0.9.1
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 +337 -92
- package/package.json +1 -1
- package/src/acp-runner.ts +2 -0
- package/src/acpx-runner.ts +2 -0
- package/src/index.ts +210 -40
- 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,31 +5601,108 @@ 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 =
|
|
5610
|
-
var VERSION = "0.
|
|
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");
|
|
5692
|
+
var VERSION = "0.9.1";
|
|
5611
5693
|
var COMMANDS = {
|
|
5612
5694
|
setup: "Register this device with a workspace",
|
|
5613
5695
|
connect: "Connect device to realtime hub and execute assigned tasks",
|
|
5614
5696
|
link: "Link this device to an agent (agent_id required)",
|
|
5615
5697
|
status: "Show current status",
|
|
5616
5698
|
stop: "Stop the running daemon",
|
|
5699
|
+
restart: "Stop and relaunch the daemon in background",
|
|
5617
5700
|
logs: "View execution logs"
|
|
5618
5701
|
};
|
|
5619
5702
|
function ensureDirs() {
|
|
5620
|
-
for (const d of [MULTI_DIR,
|
|
5621
|
-
if (!
|
|
5622
|
-
|
|
5703
|
+
for (const d of [MULTI_DIR, join3(MULTI_DIR, "logs"), SKILLS_DIR]) {
|
|
5704
|
+
if (!existsSync3(d))
|
|
5705
|
+
mkdirSync3(d, { recursive: true });
|
|
5623
5706
|
}
|
|
5624
5707
|
}
|
|
5625
5708
|
function log(msg) {
|
|
@@ -5679,6 +5762,9 @@ async function main() {
|
|
|
5679
5762
|
case "stop":
|
|
5680
5763
|
await cmdStop();
|
|
5681
5764
|
break;
|
|
5765
|
+
case "restart":
|
|
5766
|
+
await cmdRestart(apiUrl);
|
|
5767
|
+
break;
|
|
5682
5768
|
case "logs":
|
|
5683
5769
|
await cmdLogs();
|
|
5684
5770
|
break;
|
|
@@ -5700,6 +5786,7 @@ Commands:
|
|
|
5700
5786
|
connect ${COMMANDS.connect}
|
|
5701
5787
|
status ${COMMANDS.status}
|
|
5702
5788
|
stop ${COMMANDS.stop}
|
|
5789
|
+
restart ${COMMANDS.restart}
|
|
5703
5790
|
logs ${COMMANDS.logs}
|
|
5704
5791
|
|
|
5705
5792
|
Options:
|
|
@@ -5792,7 +5879,7 @@ async function syncSkills(apiUrl, workspaceId) {
|
|
|
5792
5879
|
return;
|
|
5793
5880
|
ensureDirs();
|
|
5794
5881
|
for (const skill of res.data) {
|
|
5795
|
-
|
|
5882
|
+
writeFileSync3(join3(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
|
|
5796
5883
|
}
|
|
5797
5884
|
if (res.data.length)
|
|
5798
5885
|
console.log(` Synced ${res.data.length} skill(s) \u2192 ${SKILLS_DIR}`);
|
|
@@ -5822,8 +5909,8 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5822
5909
|
console.log('\u274C Missing dispatch secret. Re-pair via "multi-agent setup".');
|
|
5823
5910
|
process.exit(1);
|
|
5824
5911
|
}
|
|
5825
|
-
if (
|
|
5826
|
-
const pid = Number(
|
|
5912
|
+
if (existsSync3(PID_PATH)) {
|
|
5913
|
+
const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
|
|
5827
5914
|
if (pid && isRunning(pid)) {
|
|
5828
5915
|
console.log(`\u274C Daemon already running (pid ${pid}).`);
|
|
5829
5916
|
process.exit(1);
|
|
@@ -5831,21 +5918,46 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5831
5918
|
unlinkSync(PID_PATH);
|
|
5832
5919
|
}
|
|
5833
5920
|
ensureDirs();
|
|
5834
|
-
|
|
5835
|
-
if (
|
|
5921
|
+
writeFileSync3(PID_PATH, String(process.pid));
|
|
5922
|
+
if (existsSync3(STOP_PATH))
|
|
5836
5923
|
unlinkSync(STOP_PATH);
|
|
5837
5924
|
const detected = await detectAgents();
|
|
5838
5925
|
log(`\uD83D\uDE80 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
|
|
5839
5926
|
log(` runtimes: ${detected.map((d) => d.type).join(", ") || "stub"}`);
|
|
5840
5927
|
const db = openTasksDb();
|
|
5841
|
-
db.run("UPDATE tasks SET status = '
|
|
5842
|
-
|
|
5843
|
-
const
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5928
|
+
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
5929
|
+
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
|
|
5930
|
+
const running = new Map;
|
|
5931
|
+
function pickNext() {
|
|
5932
|
+
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean);
|
|
5933
|
+
const notIn = busyAgents.length ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))` : "";
|
|
5934
|
+
const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
|
|
5935
|
+
return db.query(sql).get(...busyAgents);
|
|
5936
|
+
}
|
|
5937
|
+
function schedule() {
|
|
5938
|
+
while (running.size < MAX_DEVICE) {
|
|
5939
|
+
const row = pickNext();
|
|
5940
|
+
if (!row)
|
|
5941
|
+
return;
|
|
5942
|
+
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
5943
|
+
const entry = { agentId: row.agent_id || "", startedAt: Date.now(), child: null, worktreePath: "" };
|
|
5944
|
+
const issueKey = row.issue_id || row.id;
|
|
5945
|
+
running.set(issueKey, entry);
|
|
5946
|
+
(async () => {
|
|
5947
|
+
try {
|
|
5948
|
+
const task = JSON.parse(row.payload);
|
|
5949
|
+
await handleRunTask(apiUrl, config.deviceId, task, detected, { runEntry: entry });
|
|
5950
|
+
db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
|
|
5951
|
+
} catch (e) {
|
|
5952
|
+
log(`task ${row.id} error: ${String(e)}`);
|
|
5953
|
+
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
5954
|
+
} finally {
|
|
5955
|
+
running.delete(issueKey);
|
|
5956
|
+
queueMicrotask(() => schedule());
|
|
5957
|
+
}
|
|
5958
|
+
})();
|
|
5959
|
+
}
|
|
5960
|
+
}
|
|
5849
5961
|
const port = await pickFreePort();
|
|
5850
5962
|
const expectedAuth = `Bearer ${config.dispatchSecret}`;
|
|
5851
5963
|
const server = Bun.serve({
|
|
@@ -5861,15 +5973,50 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5861
5973
|
return (async () => {
|
|
5862
5974
|
try {
|
|
5863
5975
|
const body = await req.json();
|
|
5864
|
-
const
|
|
5865
|
-
|
|
5866
|
-
|
|
5976
|
+
const t = body?.task || {};
|
|
5977
|
+
const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
5978
|
+
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]);
|
|
5979
|
+
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;
|
|
5980
|
+
if (t.issue_id) {
|
|
5981
|
+
postStream(apiUrl, t.issue_id, "queued", { queue_position: pos });
|
|
5982
|
+
}
|
|
5983
|
+
queueMicrotask(() => schedule());
|
|
5867
5984
|
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
5868
5985
|
} catch (e) {
|
|
5869
5986
|
return Response.json({ error: String(e) }, { status: 400 });
|
|
5870
5987
|
}
|
|
5871
5988
|
})();
|
|
5872
5989
|
}
|
|
5990
|
+
if (url.pathname === "/stop" && req.method === "POST") {
|
|
5991
|
+
if (req.headers.get("authorization") !== expectedAuth)
|
|
5992
|
+
return new Response("unauthorized", { status: 401 });
|
|
5993
|
+
return (async () => {
|
|
5994
|
+
try {
|
|
5995
|
+
const { issue_id } = await req.json();
|
|
5996
|
+
if (!issue_id)
|
|
5997
|
+
return Response.json({ error: "issue_id required" }, { status: 400 });
|
|
5998
|
+
const entry = running.get(issue_id);
|
|
5999
|
+
if (!entry) {
|
|
6000
|
+
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
6001
|
+
await markStopped(apiUrl, issue_id, "stopped before start");
|
|
6002
|
+
return Response.json({ ok: true, state: "queued-cancelled" });
|
|
6003
|
+
}
|
|
6004
|
+
entry.stopped = true;
|
|
6005
|
+
entry.stopReason = "user requested";
|
|
6006
|
+
try {
|
|
6007
|
+
entry.child?.kill("SIGTERM");
|
|
6008
|
+
} catch {}
|
|
6009
|
+
setTimeout(() => {
|
|
6010
|
+
try {
|
|
6011
|
+
entry.child?.kill("SIGKILL");
|
|
6012
|
+
} catch {}
|
|
6013
|
+
}, 5000);
|
|
6014
|
+
return Response.json({ ok: true, state: "running-signalled" });
|
|
6015
|
+
} catch (e) {
|
|
6016
|
+
return Response.json({ error: String(e) }, { status: 400 });
|
|
6017
|
+
}
|
|
6018
|
+
})();
|
|
6019
|
+
}
|
|
5873
6020
|
return new Response("not found", { status: 404 });
|
|
5874
6021
|
}
|
|
5875
6022
|
});
|
|
@@ -5892,11 +6039,11 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5892
6039
|
}
|
|
5893
6040
|
log(`\u2601\uFE0F Tunnel up: ${tunnelUrl}`);
|
|
5894
6041
|
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
|
|
5895
|
-
let
|
|
6042
|
+
let alive = true;
|
|
5896
6043
|
const shutdown = async (reason) => {
|
|
5897
|
-
if (!
|
|
6044
|
+
if (!alive)
|
|
5898
6045
|
return;
|
|
5899
|
-
|
|
6046
|
+
alive = false;
|
|
5900
6047
|
log(`\uD83D\uDED1 Shutting down (${reason})`);
|
|
5901
6048
|
try {
|
|
5902
6049
|
server.stop();
|
|
@@ -5907,9 +6054,9 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5907
6054
|
try {
|
|
5908
6055
|
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
|
|
5909
6056
|
} catch {}
|
|
5910
|
-
if (
|
|
6057
|
+
if (existsSync3(PID_PATH))
|
|
5911
6058
|
unlinkSync(PID_PATH);
|
|
5912
|
-
if (
|
|
6059
|
+
if (existsSync3(STOP_PATH))
|
|
5913
6060
|
unlinkSync(STOP_PATH);
|
|
5914
6061
|
db.close();
|
|
5915
6062
|
log("\uD83D\uDC4B Disconnected");
|
|
@@ -5917,30 +6064,10 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5917
6064
|
};
|
|
5918
6065
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
5919
6066
|
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) {
|
|
6067
|
+
schedule();
|
|
6068
|
+
while (alive) {
|
|
5942
6069
|
await sleep(20000);
|
|
5943
|
-
if (
|
|
6070
|
+
if (existsSync3(STOP_PATH)) {
|
|
5944
6071
|
await shutdown("stop flag");
|
|
5945
6072
|
break;
|
|
5946
6073
|
}
|
|
@@ -5952,8 +6079,8 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5952
6079
|
}
|
|
5953
6080
|
}
|
|
5954
6081
|
async function cmdConnectDetached(apiUrl) {
|
|
5955
|
-
if (
|
|
5956
|
-
const pid = Number(
|
|
6082
|
+
if (existsSync3(PID_PATH)) {
|
|
6083
|
+
const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
|
|
5957
6084
|
if (pid && isRunning(pid)) {
|
|
5958
6085
|
console.log(`\u274C Daemon already running (pid ${pid}).`);
|
|
5959
6086
|
process.exit(1);
|
|
@@ -6010,22 +6137,48 @@ async function parseTunnelUrl(stream2) {
|
|
|
6010
6137
|
}
|
|
6011
6138
|
return null;
|
|
6012
6139
|
}
|
|
6140
|
+
async function markStopped(apiUrl, issueId, reason) {
|
|
6141
|
+
try {
|
|
6142
|
+
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "stopped" });
|
|
6143
|
+
} catch {}
|
|
6144
|
+
try {
|
|
6145
|
+
await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
|
|
6146
|
+
author_type: "agent",
|
|
6147
|
+
author_id: "daemon",
|
|
6148
|
+
author_name: "daemon",
|
|
6149
|
+
body: `\u23F9 Stopped: ${reason}`
|
|
6150
|
+
});
|
|
6151
|
+
} catch {}
|
|
6152
|
+
await postStream(apiUrl, issueId, "stopped", { reason });
|
|
6153
|
+
}
|
|
6013
6154
|
async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
|
|
6014
6155
|
const issueId = task.issue_id;
|
|
6015
6156
|
const isFollowup = !!task.followup;
|
|
6016
|
-
const
|
|
6017
|
-
|
|
6157
|
+
const baseWorkingDir = task.working_dir && existsSync3(task.working_dir) ? task.working_dir : undefined;
|
|
6158
|
+
let workingDir = baseWorkingDir;
|
|
6159
|
+
let worktreeBranch = "";
|
|
6160
|
+
if (baseWorkingDir) {
|
|
6161
|
+
try {
|
|
6162
|
+
const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
|
|
6163
|
+
workingDir = wt.path;
|
|
6164
|
+
worktreeBranch = wt.branch;
|
|
6165
|
+
await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
|
|
6166
|
+
} catch (e) {
|
|
6167
|
+
await postStream(apiUrl, issueId, "worktree_error", { message: String(e) });
|
|
6168
|
+
}
|
|
6169
|
+
}
|
|
6170
|
+
log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ""}]` : ""}`);
|
|
6018
6171
|
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "in_progress" });
|
|
6019
6172
|
await postStream(apiUrl, issueId, "progress", { message: `Device ${deviceId} picked up ${isFollowup ? "follow-up" : "task"}` });
|
|
6020
6173
|
let attachmentRefs = [];
|
|
6021
6174
|
if (task.from_comment_id) {
|
|
6022
|
-
const baseDir = workingDir ||
|
|
6023
|
-
const inDir =
|
|
6175
|
+
const baseDir = workingDir || join3(MULTI_DIR, "tmp", issueId);
|
|
6176
|
+
const inDir = join3(baseDir, ".multi-in", task.from_comment_id);
|
|
6024
6177
|
attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
|
|
6025
6178
|
if (attachmentRefs.length)
|
|
6026
6179
|
log(` fetched ${attachmentRefs.length} attachment(s) \u2192 ${inDir}`);
|
|
6027
6180
|
}
|
|
6028
|
-
const outDir =
|
|
6181
|
+
const outDir = join3(workingDir || join3(MULTI_DIR, "tmp", issueId), ".multi-out");
|
|
6029
6182
|
let liveCommentId;
|
|
6030
6183
|
let liveBody = "";
|
|
6031
6184
|
let hadError = false;
|
|
@@ -6333,6 +6486,7 @@ ${userPart}` : userPart;
|
|
|
6333
6486
|
if (!adapterBin)
|
|
6334
6487
|
throw new Error(`ACP adapter for ${chosen.type} not found`);
|
|
6335
6488
|
log(` adapter: ${chosen.type} \u2192 ${adapterBin.join(" ")}`);
|
|
6489
|
+
const startedAt = Date.now();
|
|
6336
6490
|
const { sessionId } = await runAcp({
|
|
6337
6491
|
apiUrl,
|
|
6338
6492
|
issueId,
|
|
@@ -6347,8 +6501,16 @@ ${userPart}` : userPart;
|
|
|
6347
6501
|
try {
|
|
6348
6502
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid });
|
|
6349
6503
|
} catch {}
|
|
6504
|
+
},
|
|
6505
|
+
onSpawn: (child) => {
|
|
6506
|
+
if (ctx?.runEntry) {
|
|
6507
|
+
ctx.runEntry.child = child;
|
|
6508
|
+
ctx.runEntry.worktreePath = workingDir || "";
|
|
6509
|
+
}
|
|
6510
|
+
postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
|
|
6350
6511
|
}
|
|
6351
6512
|
});
|
|
6513
|
+
postStream(apiUrl, issueId, "run_finished", { stopReason: typeof sessionId === "string" ? "ok" : "unknown", duration_ms: Date.now() - startedAt });
|
|
6352
6514
|
log(` acp session ${sessionId.slice(0, 8)}`);
|
|
6353
6515
|
} else if (useAcpx) {
|
|
6354
6516
|
let preamble = "";
|
|
@@ -6416,13 +6578,22 @@ Write generated files to: ${outDir}`;
|
|
|
6416
6578
|
|
|
6417
6579
|
${userPart}` : userPart;
|
|
6418
6580
|
log(` acpx runner: ${preferType}`);
|
|
6581
|
+
const acpxStartedAt = Date.now();
|
|
6419
6582
|
await runAcpx({
|
|
6420
6583
|
agentType: preferType,
|
|
6421
6584
|
prompt: full,
|
|
6422
6585
|
cwd: workingDir,
|
|
6423
6586
|
sessionName: `issue-${issueId}`,
|
|
6424
|
-
onEvent: eventHandler
|
|
6587
|
+
onEvent: eventHandler,
|
|
6588
|
+
onSpawn: (child) => {
|
|
6589
|
+
if (ctx?.runEntry) {
|
|
6590
|
+
ctx.runEntry.child = child;
|
|
6591
|
+
ctx.runEntry.worktreePath = workingDir || "";
|
|
6592
|
+
}
|
|
6593
|
+
postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
|
|
6594
|
+
}
|
|
6425
6595
|
});
|
|
6596
|
+
postStream(apiUrl, issueId, "run_finished", { stopReason: "ok", duration_ms: Date.now() - acpxStartedAt });
|
|
6426
6597
|
} else {
|
|
6427
6598
|
const runner = pickRunner(detected, preferType);
|
|
6428
6599
|
for await (const event of runner(task))
|
|
@@ -6451,7 +6622,10 @@ ${userPart}` : userPart;
|
|
|
6451
6622
|
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
6623
|
log(` \u26A0 ${task.key} produced no assistant output (stopReason=${stopReason})`);
|
|
6453
6624
|
}
|
|
6454
|
-
if (
|
|
6625
|
+
if (ctx?.runEntry?.stopped) {
|
|
6626
|
+
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
|
|
6627
|
+
log(` \u23F9 ${task.key} stopped`);
|
|
6628
|
+
} else if (hadError) {
|
|
6455
6629
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
6456
6630
|
log(` \u2717 ${task.key} failed`);
|
|
6457
6631
|
} else {
|
|
@@ -6459,10 +6633,15 @@ ${userPart}` : userPart;
|
|
|
6459
6633
|
log(` \u2713 ${task.key} complete`);
|
|
6460
6634
|
}
|
|
6461
6635
|
} catch (e) {
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6636
|
+
if (ctx?.runEntry?.stopped) {
|
|
6637
|
+
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
|
|
6638
|
+
log(` \u23F9 ${task.key} stopped (${String(e)})`);
|
|
6639
|
+
} else {
|
|
6640
|
+
await postStream(apiUrl, issueId, "error", { message: String(e) });
|
|
6641
|
+
await postComment(`\u274C spawn error: ${String(e)}`);
|
|
6642
|
+
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
6643
|
+
log(` \u2717 ${task.key} failed: ${String(e)}`);
|
|
6644
|
+
}
|
|
6466
6645
|
}
|
|
6467
6646
|
}
|
|
6468
6647
|
async function buildPlanningPreamble(apiUrl, task) {
|
|
@@ -6564,7 +6743,11 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
|
|
|
6564
6743
|
}
|
|
6565
6744
|
})();
|
|
6566
6745
|
const headers = { "x-agent-id": parentTask.agent_id };
|
|
6567
|
-
|
|
6746
|
+
if (typeof ctx.refreshLocalAgents === "function") {
|
|
6747
|
+
try {
|
|
6748
|
+
await ctx.refreshLocalAgents();
|
|
6749
|
+
} catch {}
|
|
6750
|
+
}
|
|
6568
6751
|
for (const a of actions) {
|
|
6569
6752
|
try {
|
|
6570
6753
|
if (a.type === "create") {
|
|
@@ -6634,29 +6817,36 @@ function statusIcon(status) {
|
|
|
6634
6817
|
}
|
|
6635
6818
|
}
|
|
6636
6819
|
async function resolveAcpAdapter(agentType, detectedPath) {
|
|
6637
|
-
if (agentType === "pi" && detectedPath &&
|
|
6820
|
+
if (agentType === "pi" && detectedPath && existsSync3(detectedPath)) {
|
|
6638
6821
|
return [detectedPath, "--mode", "rpc"];
|
|
6639
6822
|
}
|
|
6640
6823
|
const adapterName = "claude-code-acp";
|
|
6641
6824
|
const candidates = [
|
|
6642
|
-
|
|
6825
|
+
join3(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
|
|
6643
6826
|
];
|
|
6644
6827
|
try {
|
|
6645
6828
|
const here = new URL(import.meta.url).pathname;
|
|
6646
6829
|
let dir = here;
|
|
6647
6830
|
for (let i = 0;i < 8; i++) {
|
|
6648
6831
|
dir = dirname2(dir);
|
|
6649
|
-
const bin =
|
|
6650
|
-
if (
|
|
6832
|
+
const bin = join3(dir, "node_modules", ".bin", adapterName);
|
|
6833
|
+
if (existsSync3(bin))
|
|
6651
6834
|
return [bin];
|
|
6652
6835
|
}
|
|
6653
6836
|
} catch {}
|
|
6654
6837
|
for (const c of candidates)
|
|
6655
|
-
if (
|
|
6838
|
+
if (existsSync3(c))
|
|
6656
6839
|
return [c];
|
|
6657
6840
|
return null;
|
|
6658
6841
|
}
|
|
6659
6842
|
async function postStream(apiUrl, issueId, event_type, payload) {
|
|
6843
|
+
try {
|
|
6844
|
+
ensureDirs();
|
|
6845
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
6846
|
+
const path = join3(MULTI_DIR, "logs", `events-${date}.ndjson`);
|
|
6847
|
+
appendFileSync2(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + `
|
|
6848
|
+
`);
|
|
6849
|
+
} catch {}
|
|
6660
6850
|
await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
|
|
6661
6851
|
}
|
|
6662
6852
|
async function downloadCommentAttachments(apiUrl, commentId, destDir) {
|
|
@@ -6665,7 +6855,7 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
|
|
|
6665
6855
|
const items = list.data?.results || list.data || [];
|
|
6666
6856
|
if (!Array.isArray(items) || items.length === 0)
|
|
6667
6857
|
return [];
|
|
6668
|
-
|
|
6858
|
+
mkdirSync3(destDir, { recursive: true });
|
|
6669
6859
|
const token = authTokenHeader();
|
|
6670
6860
|
const out = [];
|
|
6671
6861
|
for (const it of items) {
|
|
@@ -6674,8 +6864,8 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
|
|
|
6674
6864
|
continue;
|
|
6675
6865
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
6676
6866
|
const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
6677
|
-
const p =
|
|
6678
|
-
|
|
6867
|
+
const p = join3(destDir, safe);
|
|
6868
|
+
writeFileSync3(p, buf);
|
|
6679
6869
|
out.push({ filename: it.filename, path: p });
|
|
6680
6870
|
}
|
|
6681
6871
|
return out;
|
|
@@ -6688,14 +6878,14 @@ function authTokenHeader() {
|
|
|
6688
6878
|
return cfg.token ? `Bearer ${cfg.token}` : null;
|
|
6689
6879
|
}
|
|
6690
6880
|
async function uploadOutputDir(apiUrl, commentId, dir) {
|
|
6691
|
-
if (!
|
|
6881
|
+
if (!existsSync3(dir))
|
|
6692
6882
|
return 0;
|
|
6693
6883
|
const files = [];
|
|
6694
6884
|
const walk = (d, depth = 0) => {
|
|
6695
6885
|
if (depth > 3)
|
|
6696
6886
|
return;
|
|
6697
6887
|
for (const name of readdirSync(d)) {
|
|
6698
|
-
const p =
|
|
6888
|
+
const p = join3(d, name);
|
|
6699
6889
|
try {
|
|
6700
6890
|
const st = statSync(p);
|
|
6701
6891
|
if (st.isDirectory())
|
|
@@ -6712,7 +6902,7 @@ async function uploadOutputDir(apiUrl, commentId, dir) {
|
|
|
6712
6902
|
let uploaded = 0;
|
|
6713
6903
|
for (const f of files) {
|
|
6714
6904
|
try {
|
|
6715
|
-
const data =
|
|
6905
|
+
const data = readFileSync3(f);
|
|
6716
6906
|
const form = new FormData;
|
|
6717
6907
|
const blob = new Blob([data]);
|
|
6718
6908
|
form.append("file", blob, f.split("/").pop() || "file");
|
|
@@ -6939,7 +7129,7 @@ async function cmdStatus(apiUrl, config) {
|
|
|
6939
7129
|
process.exit(1);
|
|
6940
7130
|
}
|
|
6941
7131
|
const d = res.data;
|
|
6942
|
-
const pid =
|
|
7132
|
+
const pid = existsSync3(PID_PATH) ? readFileSync3(PID_PATH, "utf8").trim() : null;
|
|
6943
7133
|
const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : "stopped";
|
|
6944
7134
|
console.log(`
|
|
6945
7135
|
Device Status
|
|
@@ -6953,29 +7143,73 @@ Daemon: ${daemon}
|
|
|
6953
7143
|
`);
|
|
6954
7144
|
}
|
|
6955
7145
|
async function cmdStop() {
|
|
6956
|
-
if (!
|
|
7146
|
+
if (!existsSync3(PID_PATH)) {
|
|
6957
7147
|
console.log("No daemon running.");
|
|
6958
7148
|
return;
|
|
6959
7149
|
}
|
|
6960
|
-
const pid = Number(
|
|
7150
|
+
const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
|
|
6961
7151
|
if (!pid || !isRunning(pid)) {
|
|
6962
7152
|
unlinkSync(PID_PATH);
|
|
6963
7153
|
console.log("Cleaned stale pidfile.");
|
|
6964
7154
|
return;
|
|
6965
7155
|
}
|
|
6966
7156
|
ensureDirs();
|
|
6967
|
-
|
|
7157
|
+
writeFileSync3(STOP_PATH, "1");
|
|
6968
7158
|
try {
|
|
6969
7159
|
process.kill(pid, "SIGTERM");
|
|
6970
7160
|
console.log(`Sent SIGTERM to ${pid}`);
|
|
6971
7161
|
} catch {}
|
|
6972
7162
|
}
|
|
7163
|
+
async function cmdRestart(apiUrl) {
|
|
7164
|
+
if (existsSync3(PID_PATH)) {
|
|
7165
|
+
const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
|
|
7166
|
+
if (pid && isRunning(pid)) {
|
|
7167
|
+
ensureDirs();
|
|
7168
|
+
writeFileSync3(STOP_PATH, "1");
|
|
7169
|
+
try {
|
|
7170
|
+
process.kill(pid, "SIGTERM");
|
|
7171
|
+
} catch {}
|
|
7172
|
+
console.log(`\u23F9 Stopping daemon (pid ${pid})...`);
|
|
7173
|
+
const deadline = Date.now() + 1e4;
|
|
7174
|
+
while (Date.now() < deadline && isRunning(pid))
|
|
7175
|
+
await sleep(200);
|
|
7176
|
+
if (isRunning(pid)) {
|
|
7177
|
+
try {
|
|
7178
|
+
process.kill(pid, "SIGKILL");
|
|
7179
|
+
} catch {}
|
|
7180
|
+
await sleep(300);
|
|
7181
|
+
}
|
|
7182
|
+
}
|
|
7183
|
+
try {
|
|
7184
|
+
if (existsSync3(PID_PATH))
|
|
7185
|
+
unlinkSync(PID_PATH);
|
|
7186
|
+
} catch {}
|
|
7187
|
+
}
|
|
7188
|
+
try {
|
|
7189
|
+
if (existsSync3(STOP_PATH))
|
|
7190
|
+
unlinkSync(STOP_PATH);
|
|
7191
|
+
} catch {}
|
|
7192
|
+
console.log("\uD83D\uDD04 Relaunching daemon...");
|
|
7193
|
+
ensureDirs();
|
|
7194
|
+
const args = Bun.argv.slice(1).filter((a) => a !== "-d" && a !== "--detach" && a !== "restart");
|
|
7195
|
+
if (!args.includes("connect"))
|
|
7196
|
+
args.splice(1, 0, "connect");
|
|
7197
|
+
const proc = Bun.spawn([process.execPath, ...args, "--api", apiUrl], {
|
|
7198
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
7199
|
+
env: { ...process.env, MULTI_DETACHED: "1" }
|
|
7200
|
+
});
|
|
7201
|
+
proc.unref?.();
|
|
7202
|
+
await sleep(500);
|
|
7203
|
+
console.log(`\u2705 Daemon restarted (pid ${proc.pid}).`);
|
|
7204
|
+
console.log(` Tail logs: multi-agent logs`);
|
|
7205
|
+
process.exit(0);
|
|
7206
|
+
}
|
|
6973
7207
|
async function cmdLogs() {
|
|
6974
|
-
if (!
|
|
7208
|
+
if (!existsSync3(LOG_PATH2)) {
|
|
6975
7209
|
console.log("No logs yet.");
|
|
6976
7210
|
return;
|
|
6977
7211
|
}
|
|
6978
|
-
const content =
|
|
7212
|
+
const content = readFileSync3(LOG_PATH2, "utf8");
|
|
6979
7213
|
console.log(content.split(`
|
|
6980
7214
|
`).slice(-100).join(`
|
|
6981
7215
|
`));
|
|
@@ -6994,7 +7228,7 @@ function openTasksDb() {
|
|
|
6994
7228
|
db.exec(`
|
|
6995
7229
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
6996
7230
|
id TEXT PRIMARY KEY,
|
|
6997
|
-
status TEXT NOT NULL DEFAULT '
|
|
7231
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
6998
7232
|
payload TEXT NOT NULL,
|
|
6999
7233
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
7000
7234
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
@@ -7004,20 +7238,31 @@ function openTasksDb() {
|
|
|
7004
7238
|
);
|
|
7005
7239
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
7006
7240
|
`);
|
|
7241
|
+
const cols = db.query("PRAGMA table_info(tasks)").all();
|
|
7242
|
+
const have = new Set(cols.map((c) => c.name));
|
|
7243
|
+
if (!have.has("agent_id"))
|
|
7244
|
+
db.exec("ALTER TABLE tasks ADD COLUMN agent_id TEXT");
|
|
7245
|
+
if (!have.has("issue_id"))
|
|
7246
|
+
db.exec("ALTER TABLE tasks ADD COLUMN issue_id TEXT");
|
|
7247
|
+
db.exec(`
|
|
7248
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
|
|
7249
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
|
|
7250
|
+
`);
|
|
7251
|
+
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
|
|
7007
7252
|
return db;
|
|
7008
7253
|
}
|
|
7009
7254
|
function loadConfig() {
|
|
7010
7255
|
try {
|
|
7011
|
-
if (!
|
|
7256
|
+
if (!existsSync3(CONFIG_PATH))
|
|
7012
7257
|
return {};
|
|
7013
|
-
return JSON.parse(
|
|
7258
|
+
return JSON.parse(readFileSync3(CONFIG_PATH, "utf8"));
|
|
7014
7259
|
} catch {
|
|
7015
7260
|
return {};
|
|
7016
7261
|
}
|
|
7017
7262
|
}
|
|
7018
7263
|
function saveConfig(config) {
|
|
7019
7264
|
ensureDirs();
|
|
7020
|
-
|
|
7265
|
+
writeFileSync3(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
7021
7266
|
}
|
|
7022
7267
|
function sleep(ms) {
|
|
7023
7268
|
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';
|
|
@@ -17,7 +18,7 @@ const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
|
|
|
17
18
|
const SKILLS_DIR = join(MULTI_DIR, 'skills');
|
|
18
19
|
const STOP_PATH = join(MULTI_DIR, 'stop.flag');
|
|
19
20
|
const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
|
|
20
|
-
const VERSION = '0.
|
|
21
|
+
const VERSION = '0.9.1';
|
|
21
22
|
|
|
22
23
|
const COMMANDS = {
|
|
23
24
|
setup: 'Register this device with a workspace',
|
|
@@ -25,6 +26,7 @@ const COMMANDS = {
|
|
|
25
26
|
link: 'Link this device to an agent (agent_id required)',
|
|
26
27
|
status: 'Show current status',
|
|
27
28
|
stop: 'Stop the running daemon',
|
|
29
|
+
restart: 'Stop and relaunch the daemon in background',
|
|
28
30
|
logs: 'View execution logs',
|
|
29
31
|
} as const;
|
|
30
32
|
|
|
@@ -97,6 +99,9 @@ async function main() {
|
|
|
97
99
|
case 'stop':
|
|
98
100
|
await cmdStop();
|
|
99
101
|
break;
|
|
102
|
+
case 'restart':
|
|
103
|
+
await cmdRestart(apiUrl);
|
|
104
|
+
break;
|
|
100
105
|
case 'logs':
|
|
101
106
|
await cmdLogs();
|
|
102
107
|
break;
|
|
@@ -119,6 +124,7 @@ Commands:
|
|
|
119
124
|
connect ${COMMANDS.connect}
|
|
120
125
|
status ${COMMANDS.status}
|
|
121
126
|
stop ${COMMANDS.stop}
|
|
127
|
+
restart ${COMMANDS.restart}
|
|
122
128
|
logs ${COMMANDS.logs}
|
|
123
129
|
|
|
124
130
|
Options:
|
|
@@ -252,10 +258,44 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
252
258
|
const db = openTasksDb();
|
|
253
259
|
|
|
254
260
|
// Requeue orphaned 'running' tasks from previous crash
|
|
255
|
-
db.run("UPDATE tasks SET status = '
|
|
261
|
+
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
262
|
+
|
|
263
|
+
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
|
|
264
|
+
const running = new Map<string, RunEntry>();
|
|
265
|
+
|
|
266
|
+
function pickNext(): { id: string; payload: string; agent_id: string | null; issue_id: string | null } | null {
|
|
267
|
+
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean) as string[];
|
|
268
|
+
// Emulate NOT IN (:list) via string construction (safe: only ids from DB).
|
|
269
|
+
const notIn = busyAgents.length
|
|
270
|
+
? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`
|
|
271
|
+
: '';
|
|
272
|
+
const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
|
|
273
|
+
return db.query(sql).get(...busyAgents) as any;
|
|
274
|
+
}
|
|
256
275
|
|
|
257
|
-
|
|
258
|
-
|
|
276
|
+
function schedule() {
|
|
277
|
+
while (running.size < MAX_DEVICE) {
|
|
278
|
+
const row = pickNext();
|
|
279
|
+
if (!row) return;
|
|
280
|
+
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
281
|
+
const entry: RunEntry = { agentId: row.agent_id || '', startedAt: Date.now(), child: null, worktreePath: '' };
|
|
282
|
+
const issueKey = row.issue_id || row.id;
|
|
283
|
+
running.set(issueKey, entry);
|
|
284
|
+
void (async () => {
|
|
285
|
+
try {
|
|
286
|
+
const task = JSON.parse(row.payload);
|
|
287
|
+
await handleRunTask(apiUrl, config.deviceId!, task, detected, { runEntry: entry });
|
|
288
|
+
db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
log(`task ${row.id} error: ${String(e)}`);
|
|
291
|
+
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
292
|
+
} finally {
|
|
293
|
+
running.delete(issueKey);
|
|
294
|
+
queueMicrotask(() => schedule());
|
|
295
|
+
}
|
|
296
|
+
})();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
259
299
|
|
|
260
300
|
|
|
261
301
|
// Local HTTP server on a free port
|
|
@@ -271,15 +311,45 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
271
311
|
return (async () => {
|
|
272
312
|
try {
|
|
273
313
|
const body = await req.json() as { task: any };
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
314
|
+
const t = body?.task || {};
|
|
315
|
+
const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
316
|
+
db.run(
|
|
317
|
+
"INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
|
|
318
|
+
[taskId, JSON.stringify(t), t.agent_id ?? null, t.issue_id ?? null],
|
|
319
|
+
);
|
|
320
|
+
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;
|
|
321
|
+
if (t.issue_id) {
|
|
322
|
+
void postStream(apiUrl, t.issue_id, 'queued', { queue_position: pos });
|
|
323
|
+
}
|
|
324
|
+
queueMicrotask(() => schedule());
|
|
277
325
|
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
278
326
|
} catch (e) {
|
|
279
327
|
return Response.json({ error: String(e) }, { status: 400 });
|
|
280
328
|
}
|
|
281
329
|
})();
|
|
282
330
|
}
|
|
331
|
+
if (url.pathname === '/stop' && req.method === 'POST') {
|
|
332
|
+
if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
|
|
333
|
+
return (async () => {
|
|
334
|
+
try {
|
|
335
|
+
const { issue_id } = await req.json() as { issue_id: string };
|
|
336
|
+
if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
|
|
337
|
+
const entry = running.get(issue_id);
|
|
338
|
+
if (!entry) {
|
|
339
|
+
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
340
|
+
await markStopped(apiUrl, issue_id, 'stopped before start');
|
|
341
|
+
return Response.json({ ok: true, state: 'queued-cancelled' });
|
|
342
|
+
}
|
|
343
|
+
entry.stopped = true;
|
|
344
|
+
entry.stopReason = 'user requested';
|
|
345
|
+
try { entry.child?.kill('SIGTERM'); } catch {}
|
|
346
|
+
setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
|
|
347
|
+
return Response.json({ ok: true, state: 'running-signalled' });
|
|
348
|
+
} catch (e) {
|
|
349
|
+
return Response.json({ error: String(e) }, { status: 400 });
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
352
|
+
}
|
|
283
353
|
return new Response('not found', { status: 404 });
|
|
284
354
|
},
|
|
285
355
|
});
|
|
@@ -300,11 +370,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
300
370
|
|
|
301
371
|
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
|
|
302
372
|
|
|
303
|
-
let
|
|
373
|
+
let alive = true;
|
|
304
374
|
|
|
305
375
|
const shutdown = async (reason: string) => {
|
|
306
|
-
if (!
|
|
307
|
-
|
|
376
|
+
if (!alive) return;
|
|
377
|
+
alive = false;
|
|
308
378
|
log(`🛑 Shutting down (${reason})`);
|
|
309
379
|
try { server.stop(); } catch {}
|
|
310
380
|
try { cf.kill(); } catch {}
|
|
@@ -318,28 +388,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
318
388
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
319
389
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
320
390
|
|
|
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
|
-
})();
|
|
391
|
+
// Kick the scheduler on startup to drain any leftover queued rows.
|
|
392
|
+
schedule();
|
|
340
393
|
|
|
341
394
|
// Heartbeat loop
|
|
342
|
-
while (
|
|
395
|
+
while (alive) {
|
|
343
396
|
await sleep(20000);
|
|
344
397
|
if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
|
|
345
398
|
try {
|
|
@@ -407,15 +460,52 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
|
|
|
407
460
|
return null;
|
|
408
461
|
}
|
|
409
462
|
|
|
463
|
+
interface RunEntry {
|
|
464
|
+
agentId: string;
|
|
465
|
+
startedAt: number;
|
|
466
|
+
child: any | null;
|
|
467
|
+
worktreePath: string;
|
|
468
|
+
stopped?: boolean;
|
|
469
|
+
stopReason?: string;
|
|
470
|
+
}
|
|
471
|
+
|
|
410
472
|
interface RuntimeCtx {
|
|
411
|
-
|
|
473
|
+
runEntry?: RunEntry;
|
|
474
|
+
refreshLocalAgents?: () => Promise<void>;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function markStopped(apiUrl: string, issueId: string, reason: string) {
|
|
478
|
+
try {
|
|
479
|
+
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'stopped' });
|
|
480
|
+
} catch {}
|
|
481
|
+
try {
|
|
482
|
+
await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
|
|
483
|
+
author_type: 'agent', author_id: 'daemon', author_name: 'daemon', body: `⏹ Stopped: ${reason}`,
|
|
484
|
+
});
|
|
485
|
+
} catch {}
|
|
486
|
+
await postStream(apiUrl, issueId, 'stopped', { reason });
|
|
412
487
|
}
|
|
413
488
|
|
|
414
489
|
async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[], ctx?: RuntimeCtx) {
|
|
415
490
|
const issueId = task.issue_id;
|
|
416
491
|
const isFollowup = !!task.followup;
|
|
417
|
-
const
|
|
418
|
-
|
|
492
|
+
const baseWorkingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
|
|
493
|
+
|
|
494
|
+
// Per-issue worktree isolation. Falls back to baseWorkingDir on failure or non-git.
|
|
495
|
+
let workingDir = baseWorkingDir;
|
|
496
|
+
let worktreeBranch = '';
|
|
497
|
+
if (baseWorkingDir) {
|
|
498
|
+
try {
|
|
499
|
+
const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
|
|
500
|
+
workingDir = wt.path;
|
|
501
|
+
worktreeBranch = wt.branch;
|
|
502
|
+
await postStream(apiUrl, issueId, 'worktree_created', { path: wt.path, branch: wt.branch, reused: !wt.created });
|
|
503
|
+
} catch (e) {
|
|
504
|
+
await postStream(apiUrl, issueId, 'worktree_error', { message: String(e) });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ''}]` : ''}`);
|
|
419
509
|
|
|
420
510
|
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
|
|
421
511
|
await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
|
|
@@ -678,6 +768,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
678
768
|
if (!adapterBin) throw new Error(`ACP adapter for ${chosen.type} not found`);
|
|
679
769
|
log(` adapter: ${chosen.type} → ${adapterBin.join(' ')}`);
|
|
680
770
|
|
|
771
|
+
const startedAt = Date.now();
|
|
681
772
|
const { sessionId } = await runAcp({
|
|
682
773
|
apiUrl, issueId, deviceId, prompt,
|
|
683
774
|
sessionId: task.session_id || null,
|
|
@@ -688,7 +779,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
688
779
|
onSession: async (sid) => {
|
|
689
780
|
try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid }); } catch {}
|
|
690
781
|
},
|
|
782
|
+
onSpawn: (child) => {
|
|
783
|
+
if (ctx?.runEntry) {
|
|
784
|
+
ctx.runEntry.child = child;
|
|
785
|
+
ctx.runEntry.worktreePath = workingDir || '';
|
|
786
|
+
}
|
|
787
|
+
void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
|
|
788
|
+
},
|
|
691
789
|
});
|
|
790
|
+
void postStream(apiUrl, issueId, 'run_finished', { stopReason: (typeof sessionId === 'string' ? 'ok' : 'unknown'), duration_ms: Date.now() - startedAt });
|
|
692
791
|
log(` acp session ${sessionId.slice(0, 8)}`);
|
|
693
792
|
} else if (useAcpx) {
|
|
694
793
|
// Build prompt with preamble (same logic as ACP path, but as one string)
|
|
@@ -724,13 +823,22 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
724
823
|
}
|
|
725
824
|
const full = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
|
|
726
825
|
log(` acpx runner: ${preferType}`);
|
|
826
|
+
const acpxStartedAt = Date.now();
|
|
727
827
|
await runAcpx({
|
|
728
828
|
agentType: preferType!,
|
|
729
829
|
prompt: full,
|
|
730
830
|
cwd: workingDir,
|
|
731
831
|
sessionName: `issue-${issueId}`,
|
|
732
832
|
onEvent: eventHandler,
|
|
833
|
+
onSpawn: (child) => {
|
|
834
|
+
if (ctx?.runEntry) {
|
|
835
|
+
ctx.runEntry.child = child;
|
|
836
|
+
ctx.runEntry.worktreePath = workingDir || '';
|
|
837
|
+
}
|
|
838
|
+
void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
|
|
839
|
+
},
|
|
733
840
|
});
|
|
841
|
+
void postStream(apiUrl, issueId, 'run_finished', { stopReason: 'ok', duration_ms: Date.now() - acpxStartedAt });
|
|
734
842
|
} else {
|
|
735
843
|
const runner = pickRunner(detected, preferType);
|
|
736
844
|
for await (const event of runner(task)) await eventHandler(event);
|
|
@@ -761,13 +869,21 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
761
869
|
log(` ⚠ ${task.key} produced no assistant output (stopReason=${stopReason})`);
|
|
762
870
|
}
|
|
763
871
|
|
|
764
|
-
if (
|
|
872
|
+
if (ctx?.runEntry?.stopped) {
|
|
873
|
+
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
|
|
874
|
+
log(` ⏹ ${task.key} stopped`);
|
|
875
|
+
} else if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
|
|
765
876
|
else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
|
|
766
877
|
} catch (e) {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
878
|
+
if (ctx?.runEntry?.stopped) {
|
|
879
|
+
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
|
|
880
|
+
log(` ⏹ ${task.key} stopped (${String(e)})`);
|
|
881
|
+
} else {
|
|
882
|
+
await postStream(apiUrl, issueId, 'error', { message: String(e) });
|
|
883
|
+
await postComment(`❌ spawn error: ${String(e)}`);
|
|
884
|
+
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
885
|
+
log(` ✗ ${task.key} failed: ${String(e)}`);
|
|
886
|
+
}
|
|
771
887
|
}
|
|
772
888
|
}
|
|
773
889
|
|
|
@@ -878,7 +994,9 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
|
|
|
878
994
|
const headers = { 'x-agent-id': parentTask.agent_id };
|
|
879
995
|
|
|
880
996
|
// Refresh the set of agents linked to this device once per plan execution.
|
|
881
|
-
|
|
997
|
+
if (typeof ctx.refreshLocalAgents === 'function') {
|
|
998
|
+
try { await ctx.refreshLocalAgents(); } catch {}
|
|
999
|
+
}
|
|
882
1000
|
|
|
883
1001
|
for (const a of actions) {
|
|
884
1002
|
try {
|
|
@@ -957,6 +1075,13 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
|
|
|
957
1075
|
}
|
|
958
1076
|
|
|
959
1077
|
async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
|
|
1078
|
+
// Local ndjson sink for tail -f debugging.
|
|
1079
|
+
try {
|
|
1080
|
+
ensureDirs();
|
|
1081
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
1082
|
+
const path = join(MULTI_DIR, 'logs', `events-${date}.ndjson`);
|
|
1083
|
+
appendFileSync(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + '\n');
|
|
1084
|
+
} catch {}
|
|
960
1085
|
await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
|
|
961
1086
|
}
|
|
962
1087
|
|
|
@@ -1234,6 +1359,40 @@ async function cmdStop() {
|
|
|
1234
1359
|
try { process.kill(pid, 'SIGTERM'); console.log(`Sent SIGTERM to ${pid}`); } catch {}
|
|
1235
1360
|
}
|
|
1236
1361
|
|
|
1362
|
+
async function cmdRestart(apiUrl: string) {
|
|
1363
|
+
if (existsSync(PID_PATH)) {
|
|
1364
|
+
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
1365
|
+
if (pid && isRunning(pid)) {
|
|
1366
|
+
ensureDirs();
|
|
1367
|
+
writeFileSync(STOP_PATH, '1');
|
|
1368
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
1369
|
+
console.log(`⏹ Stopping daemon (pid ${pid})...`);
|
|
1370
|
+
const deadline = Date.now() + 10_000;
|
|
1371
|
+
while (Date.now() < deadline && isRunning(pid)) await sleep(200);
|
|
1372
|
+
if (isRunning(pid)) {
|
|
1373
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
1374
|
+
await sleep(300);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
|
|
1378
|
+
}
|
|
1379
|
+
try { if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH); } catch {}
|
|
1380
|
+
console.log('🔄 Relaunching daemon...');
|
|
1381
|
+
ensureDirs();
|
|
1382
|
+
// Spawn `connect` detached (don't re-exec `restart` — would loop).
|
|
1383
|
+
const args = Bun.argv.slice(1).filter(a => a !== '-d' && a !== '--detach' && a !== 'restart');
|
|
1384
|
+
if (!args.includes('connect')) args.splice(1, 0, 'connect');
|
|
1385
|
+
const proc = Bun.spawn([process.execPath, ...args, '--api', apiUrl], {
|
|
1386
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1387
|
+
env: { ...process.env, MULTI_DETACHED: '1' },
|
|
1388
|
+
});
|
|
1389
|
+
(proc as any).unref?.();
|
|
1390
|
+
await sleep(500);
|
|
1391
|
+
console.log(`✅ Daemon restarted (pid ${proc.pid}).`);
|
|
1392
|
+
console.log(` Tail logs: multi-agent logs`);
|
|
1393
|
+
process.exit(0);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1237
1396
|
async function cmdLogs() {
|
|
1238
1397
|
if (!existsSync(LOG_PATH)) { console.log('No logs yet.'); return; }
|
|
1239
1398
|
const content = readFileSync(LOG_PATH, 'utf8');
|
|
@@ -1260,7 +1419,7 @@ function openTasksDb(): Database {
|
|
|
1260
1419
|
db.exec(`
|
|
1261
1420
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
1262
1421
|
id TEXT PRIMARY KEY,
|
|
1263
|
-
status TEXT NOT NULL DEFAULT '
|
|
1422
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
1264
1423
|
payload TEXT NOT NULL,
|
|
1265
1424
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1266
1425
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
@@ -1270,6 +1429,17 @@ function openTasksDb(): Database {
|
|
|
1270
1429
|
);
|
|
1271
1430
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
1272
1431
|
`);
|
|
1432
|
+
// Idempotent migrations: add agent_id/issue_id columns if missing.
|
|
1433
|
+
const cols = db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
|
|
1434
|
+
const have = new Set(cols.map((c) => c.name));
|
|
1435
|
+
if (!have.has('agent_id')) db.exec('ALTER TABLE tasks ADD COLUMN agent_id TEXT');
|
|
1436
|
+
if (!have.has('issue_id')) db.exec('ALTER TABLE tasks ADD COLUMN issue_id TEXT');
|
|
1437
|
+
db.exec(`
|
|
1438
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
|
|
1439
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
|
|
1440
|
+
`);
|
|
1441
|
+
// Old rows used 'pending'; normalize to 'queued'.
|
|
1442
|
+
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
|
|
1273
1443
|
return db;
|
|
1274
1444
|
}
|
|
1275
1445
|
|
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
|
+
}
|