@gleapai/kai-bridge 0.11.0 → 0.12.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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/api.mjs +10 -0
- package/src/daemon.mjs +118 -7
- package/src/deps.mjs +26 -5
- package/src/workspace.mjs +75 -21
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@gleapai/kai-bridge",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.12.0",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// POST /gleapcode/bridge/pair/poll → { status, device?, token? } (pollToken)
|
|
5
5
|
// PUT /gleapcode/bridge/devices/me/hello { name, platform, version, profiles, repos, roots }
|
|
6
6
|
// POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
|
|
7
|
+
// POST /gleapcode/bridge/turns/:id/ack { via } → 410 when the turn already ended
|
|
7
8
|
// POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
|
|
8
9
|
// POST /gleapcode/bridge/turns/:id/result { result, changes, status }
|
|
9
10
|
// POST /gleapcode/bridge/devices/me/public-hosts { sessionId, services } → { domain, hosts, tunnel, displaced }
|
|
@@ -96,6 +97,15 @@ export class BridgeApi {
|
|
|
96
97
|
heartbeat(payload) {
|
|
97
98
|
return this.request("POST", "/gleapcode/bridge/devices/me/heartbeat", payload);
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* "This machine has the turn" — sent for every delivery of a
|
|
102
|
+
* `bridge.turn.start` (push, the Server's re-send, the pending poll); the
|
|
103
|
+
* Server stops re-sending once one lands. Short timeout: the turn does not
|
|
104
|
+
* wait on a slow Server. 410 = the turn ended before it got here.
|
|
105
|
+
*/
|
|
106
|
+
turnAck(turnId, payload) {
|
|
107
|
+
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/ack`, payload, { timeoutMs: 5_000 });
|
|
108
|
+
}
|
|
99
109
|
turnEvents(turnId, events) {
|
|
100
110
|
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/events`, { events });
|
|
101
111
|
}
|
package/src/daemon.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
|
|
|
5
5
|
//
|
|
6
6
|
// Commands arrive on `private-bridge-<deviceId>` (Sockudo / Pusher
|
|
7
7
|
// protocol, the same channel family the dashboard uses):
|
|
8
|
-
// bridge.turn.start {
|
|
8
|
+
// bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], resend?, ...AgentRunOpts }
|
|
9
|
+
// — acknowledged with POST /turns/:id/ack; the Server re-sends an
|
|
10
|
+
// unacknowledged start (`resend: n`), so a turn id seen before is ignored
|
|
9
11
|
// bridge.turn.cancel { turnId }
|
|
10
12
|
// bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
|
|
11
13
|
// answered with a {type:"steer", id, outcome} turn event
|
|
@@ -57,6 +59,8 @@ const REALTIME_RETRY_MS = 15_000;
|
|
|
57
59
|
const HEARTBEAT_MS = 30_000;
|
|
58
60
|
/** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
|
|
59
61
|
const PENDING_POLL_MS = 60_000;
|
|
62
|
+
/** Turn ids remembered after they ran, so a late duplicate delivery never runs twice (see startTurn). */
|
|
63
|
+
const HANDLED_TURNS_MAX = 200;
|
|
60
64
|
const USAGE_REFRESH_MS = 10 * 60_000;
|
|
61
65
|
// Harness model catalogues change on releases, not by the minute.
|
|
62
66
|
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
@@ -563,6 +567,42 @@ export class BridgeDaemon {
|
|
|
563
567
|
this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
|
|
564
568
|
}
|
|
565
569
|
|
|
570
|
+
get handledTurnsPath() {
|
|
571
|
+
return join(this.kaiHome, "state", "handled-turns.json");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Turn ids this machine already ran (newest last, capped). Kept on disk:
|
|
576
|
+
* a turn whose result was lost is still "running" on the Server, and the
|
|
577
|
+
* pending poll after a restart must not run it a second time.
|
|
578
|
+
*/
|
|
579
|
+
handledTurns() {
|
|
580
|
+
if (!this.handled) {
|
|
581
|
+
let ids = [];
|
|
582
|
+
try {
|
|
583
|
+
const raw = JSON.parse(readFileSync(this.handledTurnsPath, "utf8"));
|
|
584
|
+
if (Array.isArray(raw)) ids = raw.filter((id) => typeof id === "string");
|
|
585
|
+
} catch {
|
|
586
|
+
/* none yet */
|
|
587
|
+
}
|
|
588
|
+
this.handled = new Set(ids.slice(-HANDLED_TURNS_MAX));
|
|
589
|
+
}
|
|
590
|
+
return this.handled;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
rememberHandledTurn(turnId) {
|
|
594
|
+
const handled = this.handledTurns();
|
|
595
|
+
handled.delete(turnId);
|
|
596
|
+
handled.add(turnId);
|
|
597
|
+
while (handled.size > HANDLED_TURNS_MAX) handled.delete(handled.values().next().value);
|
|
598
|
+
try {
|
|
599
|
+
mkdirSync(join(this.kaiHome, "state"), { recursive: true });
|
|
600
|
+
writeFileSync(this.handledTurnsPath, JSON.stringify([...handled]));
|
|
601
|
+
} catch {
|
|
602
|
+
/* best effort */
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
566
606
|
/** Turns this machine was running when it was killed — report them dead. */
|
|
567
607
|
async reportInterruptedTurns() {
|
|
568
608
|
const entries = this.readInflight();
|
|
@@ -571,6 +611,8 @@ export class BridgeDaemon {
|
|
|
571
611
|
for (const entry of entries) {
|
|
572
612
|
const { turnId } = entry;
|
|
573
613
|
this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
|
|
614
|
+
// Reported failed below; a re-send or a poll must not start it over.
|
|
615
|
+
this.rememberHandledTurn(turnId);
|
|
574
616
|
await this.api
|
|
575
617
|
.turnResult(turnId, {
|
|
576
618
|
status: "failed",
|
|
@@ -867,8 +909,11 @@ export class BridgeDaemon {
|
|
|
867
909
|
}
|
|
868
910
|
for (const turn of pending?.turns || []) {
|
|
869
911
|
if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
|
|
912
|
+
// Ran here already and its result never landed: the Server's reaper
|
|
913
|
+
// settles it — running the work again is never the fix.
|
|
914
|
+
if (this.handledTurns().has(turn.turnId)) continue;
|
|
870
915
|
this.log("info", "turn.recovered", { turnId: turn.turnId, via });
|
|
871
|
-
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
916
|
+
void this.startTurn(turn, { via }).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
872
917
|
}
|
|
873
918
|
} catch (err) {
|
|
874
919
|
// Older server without the endpoint, or the server is restarting: the
|
|
@@ -931,7 +976,7 @@ export class BridgeDaemon {
|
|
|
931
976
|
.catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
|
|
932
977
|
return;
|
|
933
978
|
}
|
|
934
|
-
return this.startTurn(data);
|
|
979
|
+
return this.startTurn(data, { via: data?.resend ? "resend" : "push" });
|
|
935
980
|
case "bridge.turn.cancel":
|
|
936
981
|
this.running.get(data.turnId)?.ctrl.abort();
|
|
937
982
|
return;
|
|
@@ -1665,9 +1710,10 @@ export class BridgeDaemon {
|
|
|
1665
1710
|
}
|
|
1666
1711
|
|
|
1667
1712
|
/** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
|
|
1668
|
-
async bindRepos(turn) {
|
|
1713
|
+
async bindRepos(turn, { onPrepare, signal } = {}) {
|
|
1669
1714
|
const bound = [];
|
|
1670
1715
|
for (const r of turn.repos || []) {
|
|
1716
|
+
if (signal?.aborted) break; // stopped mid-prep: the caller reports the cancel
|
|
1671
1717
|
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
1672
1718
|
if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
|
|
1673
1719
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
@@ -1678,6 +1724,7 @@ export class BridgeDaemon {
|
|
|
1678
1724
|
sessionId: turn.sessionId,
|
|
1679
1725
|
title: turn.title,
|
|
1680
1726
|
gitEnv,
|
|
1727
|
+
onPrepare,
|
|
1681
1728
|
}));
|
|
1682
1729
|
bound.push({ key: r.key, ...ws });
|
|
1683
1730
|
if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
|
|
@@ -1736,11 +1783,25 @@ export class BridgeDaemon {
|
|
|
1736
1783
|
return adopted;
|
|
1737
1784
|
}
|
|
1738
1785
|
|
|
1739
|
-
|
|
1786
|
+
/**
|
|
1787
|
+
* `via` = how the turn reached us: push, resend (the Server re-sent an
|
|
1788
|
+
* unacknowledged start), poll or reconnect (pullPendingWork).
|
|
1789
|
+
*/
|
|
1790
|
+
async startTurn(turn, { via = "push" } = {}) {
|
|
1740
1791
|
const { turnId } = turn;
|
|
1741
|
-
|
|
1792
|
+
// One turn, several deliveries by design: the Server re-sends a start
|
|
1793
|
+
// until it is acknowledged, and the poll / reconnect pull replays what
|
|
1794
|
+
// the Server still expects. Only the first delivery runs. Every one is
|
|
1795
|
+
// acknowledged — a duplicate usually means the first ack was missed.
|
|
1796
|
+
if (this.running.has(turnId) || this.handledTurns().has(turnId)) {
|
|
1797
|
+
this.log("info", "turn.duplicate", { turnId, via, running: this.running.has(turnId) });
|
|
1798
|
+
await this.ackTurn(turnId, via);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1742
1801
|
const ctrl = new AbortController();
|
|
1743
1802
|
const entry = { ctrl, control: null, sessionId: turn.sessionId };
|
|
1803
|
+
// Claimed before the first await, so a second delivery arriving while
|
|
1804
|
+
// the ack is in flight is the duplicate above.
|
|
1744
1805
|
this.running.set(turnId, entry);
|
|
1745
1806
|
const releaseAwake = keepAwake();
|
|
1746
1807
|
let outcome = null;
|
|
@@ -1749,8 +1810,39 @@ export class BridgeDaemon {
|
|
|
1749
1810
|
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
|
|
1750
1811
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
1751
1812
|
try {
|
|
1813
|
+
// Receipt first: the Server stops re-sending the start.
|
|
1814
|
+
if ((await this.ackTurn(turnId, via)) === "ended") {
|
|
1815
|
+
// Stopped or reaped before it reached this machine — nobody is
|
|
1816
|
+
// waiting for it, and its result would be refused anyway.
|
|
1817
|
+
this.log("info", "turn.ended_before_start", { turnId, via });
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1752
1820
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
1753
|
-
|
|
1821
|
+
// Prep no longer blocks the daemon, so a Stop (or session close) can
|
|
1822
|
+
// land before the runner exists — and runTurn's abort listener would
|
|
1823
|
+
// never hear a signal that fired before it was added. Never start the
|
|
1824
|
+
// agent for a turn that was stopped on the way.
|
|
1825
|
+
const stoppedBeforeRun = () => {
|
|
1826
|
+
if (!ctrl.signal.aborted) return false;
|
|
1827
|
+
this.log("info", "turn.cancelled_before_run", { turnId });
|
|
1828
|
+
outcome = { status: "cancelled", changes: [], profileId: profile.id };
|
|
1829
|
+
return true;
|
|
1830
|
+
};
|
|
1831
|
+
if (stoppedBeforeRun()) return;
|
|
1832
|
+
// A fresh worktree costs a fetch, a checkout and a node_modules clone
|
|
1833
|
+
// — up to minutes for a big repo — while the dashboard already calls
|
|
1834
|
+
// the session running. Say what is happening before it starts (once
|
|
1835
|
+
// per turn; resumed worktrees and local checkouts skip it).
|
|
1836
|
+
let preparing = null;
|
|
1837
|
+
const onPrepare = () => {
|
|
1838
|
+
preparing ??= (async () => {
|
|
1839
|
+
batcher.push({ type: "tool_status", message: `Preparing workspace on ${this.config.device?.name || "this machine"}…` });
|
|
1840
|
+
await batcher.flush();
|
|
1841
|
+
})();
|
|
1842
|
+
return preparing;
|
|
1843
|
+
};
|
|
1844
|
+
const bound = await this.bindRepos(turn, { onPrepare, signal: ctrl.signal });
|
|
1845
|
+
if (stoppedBeforeRun()) return;
|
|
1754
1846
|
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
1755
1847
|
// reachable as siblings under the same worktree root or by their
|
|
1756
1848
|
// local paths — the prompt lists them.
|
|
@@ -1764,6 +1856,7 @@ export class BridgeDaemon {
|
|
|
1764
1856
|
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1765
1857
|
const previewNote = live.note;
|
|
1766
1858
|
const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
|
|
1859
|
+
if (stoppedBeforeRun()) return;
|
|
1767
1860
|
const res = await runTurn({
|
|
1768
1861
|
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
1769
1862
|
profile,
|
|
@@ -1861,12 +1954,30 @@ export class BridgeDaemon {
|
|
|
1861
1954
|
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
1862
1955
|
}
|
|
1863
1956
|
this.forgetInflight(turnId);
|
|
1957
|
+
this.rememberHandledTurn(turnId);
|
|
1864
1958
|
releaseAwake();
|
|
1865
1959
|
this.running.delete(turnId);
|
|
1866
1960
|
if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
|
|
1867
1961
|
}
|
|
1868
1962
|
}
|
|
1869
1963
|
|
|
1964
|
+
/**
|
|
1965
|
+
* Tell the Server this machine has the turn. Resolves "acked", "ended"
|
|
1966
|
+
* (410: the turn is over — it must not run), or "unknown" (an older Server
|
|
1967
|
+
* without the route, a network hiccup): the turn then runs as it always
|
|
1968
|
+
* did. Never throws.
|
|
1969
|
+
*/
|
|
1970
|
+
async ackTurn(turnId, via) {
|
|
1971
|
+
try {
|
|
1972
|
+
await this.api.turnAck(turnId, { via });
|
|
1973
|
+
return "acked";
|
|
1974
|
+
} catch (err) {
|
|
1975
|
+
if (err?.status === 410) return "ended";
|
|
1976
|
+
this.log(err?.status === 404 ? "debug" : "warn", "turn.ack.failed", { turnId, via, error: err?.message });
|
|
1977
|
+
return "unknown";
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1870
1981
|
// ── preview browser ────────────────────────────────────────────────
|
|
1871
1982
|
/** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
|
|
1872
1983
|
ensurePreviewBrowser() {
|
package/src/deps.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// yet) we leave the directory absent and the install path takes over with
|
|
12
12
|
// the lockfile-driven, cache-first command instead of a bare `npm install`.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
17
17
|
import { platform } from "node:os";
|
|
@@ -84,24 +84,45 @@ export function canSeedNodeModules({ primaryPath, cwd }) {
|
|
|
84
84
|
return { ok: true, reason: "lockfile_match", pm: b.pm };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* `cmd args` as a promise, output discarded (stderr's head kept for the
|
|
89
|
+
* reason). Asynchronous on purpose: cloning a big `node_modules` takes 30 s
|
|
90
|
+
* to 2.5 min (bridge.log: 144 s for the Gleap dashboard, 105 s for the
|
|
91
|
+
* Server), and a synchronous copy froze the daemon for all of it.
|
|
92
|
+
*/
|
|
93
|
+
function runQuiet(cmd, args) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
96
|
+
let stderr = "";
|
|
97
|
+
child.stderr.on("data", (chunk) => {
|
|
98
|
+
if (stderr.length < 2000) stderr += chunk;
|
|
99
|
+
});
|
|
100
|
+
child.on("error", reject);
|
|
101
|
+
child.on("close", (code, signal) => {
|
|
102
|
+
if (code === 0) resolve();
|
|
103
|
+
else reject(new Error(`${cmd} exited with ${code ?? signal}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
/**
|
|
88
109
|
* Copy the primary checkout's `node_modules` into a fresh worktree when
|
|
89
|
-
* the lockfiles match.
|
|
110
|
+
* the lockfiles match. Resolves `{ seeded, reason, ms }`; never rejects — a
|
|
90
111
|
* failed or partial copy is removed so the install path still runs.
|
|
91
112
|
*
|
|
92
113
|
* macOS `cp -c` clones via clonefile(2) and falls back to a plain copy on
|
|
93
114
|
* filesystems without it; `-R` without `-L` keeps symlinks as symlinks
|
|
94
115
|
* (`.bin` shims, pnpm's store links — all relative, so they stay valid).
|
|
95
116
|
*/
|
|
96
|
-
export function seedNodeModules({ primaryPath, cwd, os = platform(), exec =
|
|
117
|
+
export async function seedNodeModules({ primaryPath, cwd, os = platform(), exec = runQuiet }) {
|
|
97
118
|
const started = Date.now();
|
|
98
119
|
const check = canSeedNodeModules({ primaryPath, cwd });
|
|
99
120
|
if (!check.ok) return { seeded: false, reason: check.reason, ms: 0 };
|
|
100
121
|
const src = join(primaryPath, "node_modules");
|
|
101
122
|
const dst = join(cwd, "node_modules");
|
|
102
123
|
try {
|
|
103
|
-
if (os === "darwin") exec("cp", ["-c", "-R", src, dst]
|
|
104
|
-
else if (os === "linux") exec("cp", ["-R", "--reflink=auto", src, dst]
|
|
124
|
+
if (os === "darwin") await exec("cp", ["-c", "-R", src, dst]);
|
|
125
|
+
else if (os === "linux") await exec("cp", ["-R", "--reflink=auto", src, dst]);
|
|
105
126
|
else return { seeded: false, reason: "unsupported_platform", ms: 0 };
|
|
106
127
|
return { seeded: true, reason: check.reason, ms: Date.now() - started };
|
|
107
128
|
} catch (err) {
|
package/src/workspace.mjs
CHANGED
|
@@ -12,19 +12,64 @@
|
|
|
12
12
|
// another a worktree). Git ops ported from the retired desktop runtime's
|
|
13
13
|
// git-checkout-ops.mjs in spirit: plain `git` CLI, no libraries.
|
|
14
14
|
|
|
15
|
-
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
16
16
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
|
+
import { promisify } from "node:util";
|
|
18
19
|
|
|
19
20
|
import { seedNodeModules } from "./deps.mjs";
|
|
20
21
|
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
21
24
|
function git(cwd, args, opts = {}) {
|
|
22
25
|
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* `git` that leaves the daemon's event loop alone. Preparing a fresh
|
|
30
|
+
* worktree takes minutes on a big repo (fetch, checkout, the node_modules
|
|
31
|
+
* clone); run with execFileSync it froze the whole daemon for that long —
|
|
32
|
+
* no heartbeat (the device could read "away" after 90 s), no realtime pongs, and
|
|
33
|
+
* the next turn's start and the user's Stop sat in the queue until it was
|
|
34
|
+
* done (bridge.log 2026-09-11 and 09-18: a start logged the millisecond a
|
|
35
|
+
* 60-second node_modules clone for another turn finished). stdin is closed
|
|
36
|
+
* right away, like execFileSync's "ignore": nothing may wait on a prompt.
|
|
37
|
+
*/
|
|
38
|
+
async function gitAsync(cwd, args, opts = {}) {
|
|
39
|
+
const pending = execFileAsync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
40
|
+
pending.child.stdin?.end();
|
|
41
|
+
const { stdout } = await pending;
|
|
42
|
+
return String(stdout).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
/** Network ops only: `gitEnv` is the git-auth.mjs fallback, applied to that one command. */
|
|
26
46
|
const withGitEnv = (gitEnv, opts = {}) => (gitEnv ? { ...opts, env: { ...process.env, ...gitEnv } } : opts);
|
|
27
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Nobody can answer a prompt from a background daemon: a credential or
|
|
50
|
+
* passphrase prompt fails fast instead of hanging the fetch (git-auth.mjs
|
|
51
|
+
* then offers the Server's credentials) — the same hardening the clone path
|
|
52
|
+
* has. A user's own GIT_SSH_COMMAND wins.
|
|
53
|
+
*/
|
|
54
|
+
const nonInteractiveGitEnv = (gitEnv) => ({
|
|
55
|
+
...process.env,
|
|
56
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
57
|
+
GIT_ASKPASS: "echo",
|
|
58
|
+
SSH_ASKPASS: "echo",
|
|
59
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes",
|
|
60
|
+
...(gitEnv || {}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The prep fetch had no limit: a black-holed network (captive portal,
|
|
65
|
+
* dropped VPN) left the session "running" with nothing running. A single
|
|
66
|
+
* branch of even a large repo arrives well within this.
|
|
67
|
+
*/
|
|
68
|
+
export const FETCH_TIMEOUT_MS = 120_000;
|
|
69
|
+
|
|
70
|
+
/** The child was killed because it ran past its `timeout` (async and sync forms). */
|
|
71
|
+
const isTimeout = (err) => err?.code === "ETIMEDOUT" || (err?.killed === true && err?.signal === "SIGTERM");
|
|
72
|
+
|
|
28
73
|
/**
|
|
29
74
|
* A workspace could not be prepared for a reason that has nothing to do
|
|
30
75
|
* with the task — the Server surfaces `code` as a one-click retry instead
|
|
@@ -64,38 +109,43 @@ export function isRefLockContention(message) {
|
|
|
64
109
|
);
|
|
65
110
|
}
|
|
66
111
|
|
|
67
|
-
const
|
|
68
|
-
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
69
|
-
};
|
|
112
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
70
113
|
|
|
71
114
|
/**
|
|
72
115
|
* `git fetch origin <base>` in `primaryPath`, tolerant of ref-lock
|
|
73
|
-
* contention.
|
|
116
|
+
* contention. Resolves `{ attempts, stale }` — `stale: true` means every
|
|
74
117
|
* attempt lost the race and the existing `origin/<base>` (which the
|
|
75
|
-
* competitor just updated) is used instead. Any other fetch failure,
|
|
76
|
-
*
|
|
77
|
-
* whose `code` the Server
|
|
118
|
+
* competitor just updated) is used instead. Any other fetch failure, a
|
|
119
|
+
* fetch that runs past `timeoutMs`, or contention with no usable
|
|
120
|
+
* `origin/<base>` rejects with a WorkspaceError whose `code` the Server
|
|
121
|
+
* turns into a retry offer.
|
|
78
122
|
*/
|
|
79
|
-
export function fetchBase(primaryPath, base, { exec =
|
|
123
|
+
export async function fetchBase(primaryPath, base, { exec = gitAsync, attempts = 4, backoffMs = 400, sleep = pause, repo = primaryPath, gitEnv = null, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
80
124
|
let lastError = null;
|
|
81
125
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
82
126
|
try {
|
|
83
|
-
exec(primaryPath, ["fetch", "origin", base, "--quiet"],
|
|
127
|
+
await exec(primaryPath, ["fetch", "origin", base, "--quiet"], { timeout: timeoutMs, env: nonInteractiveGitEnv(gitEnv) });
|
|
84
128
|
return { attempts: attempt, stale: false };
|
|
85
129
|
} catch (err) {
|
|
130
|
+
if (isTimeout(err)) {
|
|
131
|
+
throw new WorkspaceError(
|
|
132
|
+
`git fetch origin ${base} in ${repo} did not finish within ${Math.round(timeoutMs / 1000)} s — check this machine's network and Git access, then retry the task.`,
|
|
133
|
+
{ code: "workspace_fetch_failed", repo, cause: err },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
86
136
|
const text = `${err?.stderr || ""}\n${err?.message || ""}`;
|
|
87
137
|
if (!isRefLockContention(text)) {
|
|
88
138
|
throw new WorkspaceError(err?.message || String(err), { code: "workspace_fetch_failed", repo, cause: err });
|
|
89
139
|
}
|
|
90
140
|
lastError = err;
|
|
91
|
-
if (attempt < attempts) sleep(backoffMs * attempt);
|
|
141
|
+
if (attempt < attempts) await sleep(backoffMs * attempt);
|
|
92
142
|
}
|
|
93
143
|
}
|
|
94
144
|
// Every attempt lost: whoever kept winning has already moved
|
|
95
145
|
// origin/<base> forward, so it is at least as fresh as our fetch
|
|
96
146
|
// would have made it.
|
|
97
147
|
try {
|
|
98
|
-
exec(primaryPath, ["rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`]);
|
|
148
|
+
await exec(primaryPath, ["rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`]);
|
|
99
149
|
return { attempts, stale: true };
|
|
100
150
|
} catch {
|
|
101
151
|
throw new WorkspaceError(
|
|
@@ -147,13 +197,16 @@ export function copyPrimaryEnvFiles(primaryPath, cwd) {
|
|
|
147
197
|
}
|
|
148
198
|
|
|
149
199
|
/**
|
|
150
|
-
* Materialise one repo binding.
|
|
200
|
+
* Materialise one repo binding. Resolves `{ cwd, mode, branch, base }`.
|
|
151
201
|
* `repo` = `{ name, primaryPath, defaultBranch }`, `binding` = `{ mode, base?, carryUncommitted? }`.
|
|
202
|
+
* `onPrepare({ repo, base })` is awaited once a fresh worktree has to be
|
|
203
|
+
* built — the slow path (fetch, checkout, node_modules) — before any of it
|
|
204
|
+
* starts; resumed worktrees and local checkouts never call it.
|
|
152
205
|
*/
|
|
153
|
-
export function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null }) {
|
|
206
|
+
export async function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null, onPrepare = null }) {
|
|
154
207
|
const mode = binding?.mode === "local" ? "local" : "worktree";
|
|
155
208
|
if (mode === "local") {
|
|
156
|
-
const branch =
|
|
209
|
+
const branch = await gitAsync(repo.primaryPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
157
210
|
return { cwd: repo.primaryPath, mode, branch, base: branch };
|
|
158
211
|
}
|
|
159
212
|
const base = binding?.base || repo.defaultBranch || "main";
|
|
@@ -164,26 +217,27 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
|
|
|
164
217
|
// Resume: the worktree from the previous turn is the session state.
|
|
165
218
|
return { cwd: dir, mode, branch, base, resumed: true };
|
|
166
219
|
}
|
|
220
|
+
await onPrepare?.({ repo: repo.name, base });
|
|
167
221
|
mkdirSync(dirname(dir), { recursive: true });
|
|
168
|
-
const fetched = fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
|
|
169
|
-
|
|
222
|
+
const fetched = await fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
|
|
223
|
+
await gitAsync(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
|
|
170
224
|
// A fresh worktree has no node_modules; clone the primary checkout's
|
|
171
225
|
// when the lockfiles match so the agent's tests and the preview boot
|
|
172
226
|
// don't each start with a 2-minute install (see deps.mjs).
|
|
173
|
-
const deps = seedNodeModules({ primaryPath: repo.primaryPath, cwd: dir });
|
|
227
|
+
const deps = await seedNodeModules({ primaryPath: repo.primaryPath, cwd: dir });
|
|
174
228
|
if (binding?.carryUncommitted) {
|
|
175
229
|
// Tracked changes as a patch; untracked (non-ignored) files copied.
|
|
176
|
-
const patch =
|
|
230
|
+
const patch = await gitAsync(repo.primaryPath, ["diff", "HEAD"]);
|
|
177
231
|
if (patch) {
|
|
178
232
|
const patchPath = join(dir, ".kai-carry.patch");
|
|
179
233
|
writeFileSync(patchPath, patch + "\n");
|
|
180
234
|
try {
|
|
181
|
-
|
|
235
|
+
await gitAsync(dir, ["apply", "--3way", patchPath]);
|
|
182
236
|
} finally {
|
|
183
237
|
rmSync(patchPath, { force: true });
|
|
184
238
|
}
|
|
185
239
|
}
|
|
186
|
-
const untracked =
|
|
240
|
+
const untracked = (await gitAsync(repo.primaryPath, ["ls-files", "--others", "--exclude-standard"])).split("\n").filter(Boolean);
|
|
187
241
|
for (const rel of untracked) {
|
|
188
242
|
try {
|
|
189
243
|
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|