@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/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 sleepSync = (ms) => {
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. Returns `{ attempts, stale }` — `stale: true` means every
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, or
76
- * contention with no usable `origin/<base>`, throws a WorkspaceError
77
- * whose `code` the Server turns into a retry offer.
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 = git, attempts = 4, backoffMs = 400, sleep = sleepSync, repo = primaryPath, gitEnv = null } = {}) {
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"], withGitEnv(gitEnv));
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. Returns `{ cwd, mode, branch, base }`.
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 = git(repo.primaryPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
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
- return { cwd: dir, mode, branch, base, resumed: true };
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
- git(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
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 = git(repo.primaryPath, ["diff", "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
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
- git(dir, ["apply", "--3way", patchPath]);
286
+ await gitAsync(dir, ["apply", "--3way", patchPath]);
182
287
  } finally {
183
288
  rmSync(patchPath, { force: true });
184
289
  }
185
290
  }
186
- const untracked = git(repo.primaryPath, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean);
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
- out.remote = git(cwd, ["remote", "get-url", "origin"]);
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) {