@gleapai/kai-bridge 0.11.0 → 0.12.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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/runner/acp-runner.mjs +75 -24
- package/runner/lib/acp/harnesses.mjs +16 -4
- package/runner/lib/acp/mapper.mjs +38 -0
- package/runner/lib/contract.mjs +18 -1
- package/src/api.mjs +10 -0
- package/src/daemon.mjs +143 -10
- package/src/deps.mjs +26 -5
- package/src/executor.mjs +123 -20
- package/src/rehydration.mjs +129 -0
- package/src/repos.mjs +63 -0
- package/src/workspace.mjs +150 -24
package/src/workspace.mjs
CHANGED
|
@@ -12,19 +12,65 @@
|
|
|
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";
|
|
21
|
+
import { stripRemoteCredentials } from "./repos.mjs";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
20
24
|
|
|
21
25
|
function git(cwd, args, opts = {}) {
|
|
22
26
|
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `git` that leaves the daemon's event loop alone. Preparing a fresh
|
|
31
|
+
* worktree takes minutes on a big repo (fetch, checkout, the node_modules
|
|
32
|
+
* clone); run with execFileSync it froze the whole daemon for that long —
|
|
33
|
+
* no heartbeat (the device could read "away" after 90 s), no realtime pongs, and
|
|
34
|
+
* the next turn's start and the user's Stop sat in the queue until it was
|
|
35
|
+
* done (bridge.log 2026-09-11 and 09-18: a start logged the millisecond a
|
|
36
|
+
* 60-second node_modules clone for another turn finished). stdin is closed
|
|
37
|
+
* right away, like execFileSync's "ignore": nothing may wait on a prompt.
|
|
38
|
+
*/
|
|
39
|
+
async function gitAsync(cwd, args, opts = {}) {
|
|
40
|
+
const pending = execFileAsync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
41
|
+
pending.child.stdin?.end();
|
|
42
|
+
const { stdout } = await pending;
|
|
43
|
+
return String(stdout).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
/** Network ops only: `gitEnv` is the git-auth.mjs fallback, applied to that one command. */
|
|
26
47
|
const withGitEnv = (gitEnv, opts = {}) => (gitEnv ? { ...opts, env: { ...process.env, ...gitEnv } } : opts);
|
|
27
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Nobody can answer a prompt from a background daemon: a credential or
|
|
51
|
+
* passphrase prompt fails fast instead of hanging the fetch (git-auth.mjs
|
|
52
|
+
* then offers the Server's credentials) — the same hardening the clone path
|
|
53
|
+
* has. A user's own GIT_SSH_COMMAND wins.
|
|
54
|
+
*/
|
|
55
|
+
const nonInteractiveGitEnv = (gitEnv) => ({
|
|
56
|
+
...process.env,
|
|
57
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
58
|
+
GIT_ASKPASS: "echo",
|
|
59
|
+
SSH_ASKPASS: "echo",
|
|
60
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes",
|
|
61
|
+
...(gitEnv || {}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The prep fetch had no limit: a black-holed network (captive portal,
|
|
66
|
+
* dropped VPN) left the session "running" with nothing running. A single
|
|
67
|
+
* branch of even a large repo arrives well within this.
|
|
68
|
+
*/
|
|
69
|
+
export const FETCH_TIMEOUT_MS = 120_000;
|
|
70
|
+
|
|
71
|
+
/** The child was killed because it ran past its `timeout` (async and sync forms). */
|
|
72
|
+
const isTimeout = (err) => err?.code === "ETIMEDOUT" || (err?.killed === true && err?.signal === "SIGTERM");
|
|
73
|
+
|
|
28
74
|
/**
|
|
29
75
|
* A workspace could not be prepared for a reason that has nothing to do
|
|
30
76
|
* with the task — the Server surfaces `code` as a one-click retry instead
|
|
@@ -64,38 +110,43 @@ export function isRefLockContention(message) {
|
|
|
64
110
|
);
|
|
65
111
|
}
|
|
66
112
|
|
|
67
|
-
const
|
|
68
|
-
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
69
|
-
};
|
|
113
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
70
114
|
|
|
71
115
|
/**
|
|
72
116
|
* `git fetch origin <base>` in `primaryPath`, tolerant of ref-lock
|
|
73
|
-
* contention.
|
|
117
|
+
* contention. Resolves `{ attempts, stale }` — `stale: true` means every
|
|
74
118
|
* 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
|
|
119
|
+
* competitor just updated) is used instead. Any other fetch failure, a
|
|
120
|
+
* fetch that runs past `timeoutMs`, or contention with no usable
|
|
121
|
+
* `origin/<base>` rejects with a WorkspaceError whose `code` the Server
|
|
122
|
+
* turns into a retry offer.
|
|
78
123
|
*/
|
|
79
|
-
export function fetchBase(primaryPath, base, { exec =
|
|
124
|
+
export async function fetchBase(primaryPath, base, { exec = gitAsync, attempts = 4, backoffMs = 400, sleep = pause, repo = primaryPath, gitEnv = null, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
80
125
|
let lastError = null;
|
|
81
126
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
82
127
|
try {
|
|
83
|
-
exec(primaryPath, ["fetch", "origin", base, "--quiet"],
|
|
128
|
+
await exec(primaryPath, ["fetch", "origin", base, "--quiet"], { timeout: timeoutMs, env: nonInteractiveGitEnv(gitEnv) });
|
|
84
129
|
return { attempts: attempt, stale: false };
|
|
85
130
|
} catch (err) {
|
|
131
|
+
if (isTimeout(err)) {
|
|
132
|
+
throw new WorkspaceError(
|
|
133
|
+
`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.`,
|
|
134
|
+
{ code: "workspace_fetch_failed", repo, cause: err },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
86
137
|
const text = `${err?.stderr || ""}\n${err?.message || ""}`;
|
|
87
138
|
if (!isRefLockContention(text)) {
|
|
88
139
|
throw new WorkspaceError(err?.message || String(err), { code: "workspace_fetch_failed", repo, cause: err });
|
|
89
140
|
}
|
|
90
141
|
lastError = err;
|
|
91
|
-
if (attempt < attempts) sleep(backoffMs * attempt);
|
|
142
|
+
if (attempt < attempts) await sleep(backoffMs * attempt);
|
|
92
143
|
}
|
|
93
144
|
}
|
|
94
145
|
// Every attempt lost: whoever kept winning has already moved
|
|
95
146
|
// origin/<base> forward, so it is at least as fresh as our fetch
|
|
96
147
|
// would have made it.
|
|
97
148
|
try {
|
|
98
|
-
exec(primaryPath, ["rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`]);
|
|
149
|
+
await exec(primaryPath, ["rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`]);
|
|
99
150
|
return { attempts, stale: true };
|
|
100
151
|
} catch {
|
|
101
152
|
throw new WorkspaceError(
|
|
@@ -115,6 +166,33 @@ export function worktreePath(kaiHome, repoName, slug) {
|
|
|
115
166
|
return join(kaiHome, "worktrees", repoName, slug);
|
|
116
167
|
}
|
|
117
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The branch a restarted session worktree moves to (see materializeBinding).
|
|
171
|
+
* It never reuses the merged branch's name: after a squash merge that
|
|
172
|
+
* branch is still on the remote and rejects the new history as a
|
|
173
|
+
* non-fast-forward push (or it was deleted), and a second pull request on
|
|
174
|
+
* a merged PR's branch name is ambiguous. The cloud names its follow-up
|
|
175
|
+
* branches the same way (`kai-code/<taskId>-<stamp>`).
|
|
176
|
+
*/
|
|
177
|
+
export const restartedBranchName = (sessionBranch, stamp = Date.now().toString(36)) => `${sessionBranch}-${stamp}`;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The branch a resumed session worktree is on: `sessionBranch` or a
|
|
181
|
+
* restart of it (`<sessionBranch>-<stamp>`). Anything else (a detached
|
|
182
|
+
* HEAD, a branch the agent switched to, the base branch itself) reads as
|
|
183
|
+
* `sessionBranch`: the turn pushes `HEAD:<branch>`, and pushing to
|
|
184
|
+
* whatever happens to be checked out could publish onto the base branch.
|
|
185
|
+
*/
|
|
186
|
+
async function sessionBranchOf(cwd, sessionBranch) {
|
|
187
|
+
try {
|
|
188
|
+
const head = await gitAsync(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
189
|
+
if (head === sessionBranch || head.startsWith(`${sessionBranch}-`)) return head;
|
|
190
|
+
} catch {
|
|
191
|
+
/* unreadable: the name the worktree was created with */
|
|
192
|
+
}
|
|
193
|
+
return sessionBranch;
|
|
194
|
+
}
|
|
195
|
+
|
|
118
196
|
/**
|
|
119
197
|
* Copy the primary checkout's local env files (.env, .env.local, …) into a
|
|
120
198
|
* worktree when the worktree doesn't have its own. They're gitignored, so a
|
|
@@ -147,13 +225,28 @@ export function copyPrimaryEnvFiles(primaryPath, cwd) {
|
|
|
147
225
|
}
|
|
148
226
|
|
|
149
227
|
/**
|
|
150
|
-
* Materialise one repo binding.
|
|
151
|
-
* `repo` = `{ name, primaryPath, defaultBranch }`, `binding` = `{ mode, base?, carryUncommitted? }`.
|
|
228
|
+
* Materialise one repo binding. Resolves `{ cwd, mode, branch, base }`.
|
|
229
|
+
* `repo` = `{ name, primaryPath, defaultBranch }`, `binding` = `{ mode, base?, carryUncommitted?, mergedBranches? }`.
|
|
230
|
+
* `onPrepare({ repo, base })` is awaited once a fresh worktree has to be
|
|
231
|
+
* built, or a merged one restarted — the slow path (fetch, checkout,
|
|
232
|
+
* node_modules) — before any of it starts; other resumed worktrees and
|
|
233
|
+
* local checkouts never call it.
|
|
234
|
+
*
|
|
235
|
+
* `mergedBranches` (from the Server): this repo's session branches whose
|
|
236
|
+
* pull request merged with no open one left on them. A resumed worktree
|
|
237
|
+
* still on one of them restarts in place from a freshly fetched
|
|
238
|
+
* `origin/<base>` on a new branch (restartedBranchName) and resolves
|
|
239
|
+
* `restarted: { from }`. Kept going, a follow-up pushed the merged commits
|
|
240
|
+
* again, and the Server opened a new pull request carrying them (the
|
|
241
|
+
* merged one no longer counts as open). Uncommitted changes come along
|
|
242
|
+
* when they fit the new base; when git refuses the switch (they conflict)
|
|
243
|
+
* the worktree stays as it is and resolves `restartSkipped` with git's
|
|
244
|
+
* reason: never lose work.
|
|
152
245
|
*/
|
|
153
|
-
export function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null }) {
|
|
246
|
+
export async function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null, onPrepare = null, stamp = undefined }) {
|
|
154
247
|
const mode = binding?.mode === "local" ? "local" : "worktree";
|
|
155
248
|
if (mode === "local") {
|
|
156
|
-
const branch =
|
|
249
|
+
const branch = await gitAsync(repo.primaryPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
157
250
|
return { cwd: repo.primaryPath, mode, branch, base: branch };
|
|
158
251
|
}
|
|
159
252
|
const base = binding?.base || repo.defaultBranch || "main";
|
|
@@ -162,28 +255,40 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
|
|
|
162
255
|
const branch = `${branchPrefix}/${slug}`;
|
|
163
256
|
if (existsSync(dir)) {
|
|
164
257
|
// Resume: the worktree from the previous turn is the session state.
|
|
165
|
-
|
|
258
|
+
const current = await sessionBranchOf(dir, branch);
|
|
259
|
+
if (!binding?.mergedBranches?.includes(current)) return { cwd: dir, mode, branch: current, base, resumed: true };
|
|
260
|
+
await onPrepare?.({ repo: repo.name, base });
|
|
261
|
+
const fetched = await fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
|
|
262
|
+
const next = restartedBranchName(branch, stamp);
|
|
263
|
+
try {
|
|
264
|
+
await gitAsync(dir, ["checkout", "-q", "-b", next, `origin/${base}`]);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const reason = String(err?.stderr || err?.message || err).trim().slice(0, 500);
|
|
267
|
+
return { cwd: dir, mode, branch: current, base, resumed: true, fetch: fetched, restartSkipped: reason };
|
|
268
|
+
}
|
|
269
|
+
return { cwd: dir, mode, branch: next, base, resumed: true, restarted: { from: current }, fetch: fetched };
|
|
166
270
|
}
|
|
271
|
+
await onPrepare?.({ repo: repo.name, base });
|
|
167
272
|
mkdirSync(dirname(dir), { recursive: true });
|
|
168
|
-
const fetched = fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
|
|
169
|
-
|
|
273
|
+
const fetched = await fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
|
|
274
|
+
await gitAsync(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
|
|
170
275
|
// A fresh worktree has no node_modules; clone the primary checkout's
|
|
171
276
|
// when the lockfiles match so the agent's tests and the preview boot
|
|
172
277
|
// don't each start with a 2-minute install (see deps.mjs).
|
|
173
|
-
const deps = seedNodeModules({ primaryPath: repo.primaryPath, cwd: dir });
|
|
278
|
+
const deps = await seedNodeModules({ primaryPath: repo.primaryPath, cwd: dir });
|
|
174
279
|
if (binding?.carryUncommitted) {
|
|
175
280
|
// Tracked changes as a patch; untracked (non-ignored) files copied.
|
|
176
|
-
const patch =
|
|
281
|
+
const patch = await gitAsync(repo.primaryPath, ["diff", "HEAD"]);
|
|
177
282
|
if (patch) {
|
|
178
283
|
const patchPath = join(dir, ".kai-carry.patch");
|
|
179
284
|
writeFileSync(patchPath, patch + "\n");
|
|
180
285
|
try {
|
|
181
|
-
|
|
286
|
+
await gitAsync(dir, ["apply", "--3way", patchPath]);
|
|
182
287
|
} finally {
|
|
183
288
|
rmSync(patchPath, { force: true });
|
|
184
289
|
}
|
|
185
290
|
}
|
|
186
|
-
const untracked =
|
|
291
|
+
const untracked = (await gitAsync(repo.primaryPath, ["ls-files", "--others", "--exclude-standard"])).split("\n").filter(Boolean);
|
|
187
292
|
for (const rel of untracked) {
|
|
188
293
|
try {
|
|
189
294
|
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
|
@@ -249,6 +354,26 @@ export function discardChanges(cwd) {
|
|
|
249
354
|
return { discarded: before };
|
|
250
355
|
}
|
|
251
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Does a resumed session worktree already carry an earlier turn's work?
|
|
359
|
+
* Commits on the session branch beyond `base` (tried as `origin/<base>`
|
|
360
|
+
* first), or changes a turn left uncommitted (a stopped or failed build).
|
|
361
|
+
* A plan turn leaves neither. A rehydrated follow-up is told to review
|
|
362
|
+
* that work before it continues (rehydration.mjs). Never throws.
|
|
363
|
+
*/
|
|
364
|
+
export async function hasPriorWork(cwd, base) {
|
|
365
|
+
try {
|
|
366
|
+
if (await gitAsync(cwd, ["status", "--porcelain"])) return true;
|
|
367
|
+
for (const ref of [base && `origin/${base}`, base].filter(Boolean)) {
|
|
368
|
+
const commit = await gitAsync(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(() => "");
|
|
369
|
+
if (commit) return Number(await gitAsync(cwd, ["rev-list", "--count", `${commit}..HEAD`])) > 0;
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
/* not a checkout — nothing to review */
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
252
377
|
/** Drop a session's worktrees (after merge/close). */
|
|
253
378
|
export function removeWorktree({ kaiHome, repo, sessionId, title }) {
|
|
254
379
|
const dir = worktreePath(kaiHome, repo.name, sessionSlug(sessionId, title));
|
|
@@ -361,7 +486,8 @@ export function commitAndPush(cwd, { branch, message, allowEmpty = false, allowD
|
|
|
361
486
|
out.committed = true;
|
|
362
487
|
}
|
|
363
488
|
out.commitSha = git(cwd, ["rev-parse", "HEAD"]);
|
|
364
|
-
|
|
489
|
+
// Reported to the Server with the turn result: never with a token in it.
|
|
490
|
+
out.remote = stripRemoteCredentials(git(cwd, ["remote", "get-url", "origin"]));
|
|
365
491
|
git(cwd, ["push", "-u", "origin", `HEAD:${branch}`], withGitEnv(gitEnv, { timeout: 120_000 }));
|
|
366
492
|
out.pushed = true;
|
|
367
493
|
} catch (err) {
|