@sagentlab/navarch-runtime 0.1.91 → 0.1.93

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.
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeConversationText = sanitizeConversationText;
4
+ exports.takeChunk = takeChunk;
5
+ exports.httpStatus = httpStatus;
6
+ exports.runConversationTurn = runConversationTurn;
7
+ exports.recoverUnfinishedTurn = recoverUnfinishedTurn;
8
+ const node_crypto_1 = require("node:crypto");
9
+ const node_perf_hooks_1 = require("node:perf_hooks");
10
+ const logger_cjs_1 = require("../logger.cjs");
11
+ const prompt_cjs_1 = require("./prompt.cjs");
12
+ const workspace_cjs_1 = require("./workspace.cjs");
13
+ // Bounds mirror lib/navarch/navi-conversations.ts CONVERSATION_LIMITS.
14
+ const MAX_DELTA_CHARS = 4_000;
15
+ const SIZE_FLUSH_CHARS = 3_500;
16
+ // A conversation holds at most 512 events across 20 turns, three of them status
17
+ // events per turn. Twenty paced text appends per turn keeps 20 long turns
18
+ // (20 x 23 = 460) under that cap without knowing what earlier turns used.
19
+ const MAX_TEXT_APPENDS = 20;
20
+ // Appends are retried until the lease runs out; the terminal gets a few attempts.
21
+ const TERMINAL_ATTEMPTS = 4;
22
+ const MAX_RETRY_DELAY_MS = 2_000;
23
+ /** Server lease length (CONVERSATION_LIMITS.leaseMs). */
24
+ const LEASE_MS = 60_000;
25
+ const RECOVERY_GIVE_UP_MS = 15 * 60_000;
26
+ const EMPTY = {
27
+ firstEventMs: null, firstTextMs: null, firstTextAckedMs: null, textAppends: 0, toolStatuses: 0,
28
+ };
29
+ /** PostgreSQL JSONB rejects NUL and unpaired surrogates (server validConversationText). */
30
+ function sanitizeConversationText(text) {
31
+ return text
32
+ .replace(/\u0000/g, "")
33
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
34
+ }
35
+ /** Splits at most `limit` UTF-16 units off the front without cutting a surrogate pair. */
36
+ function takeChunk(text, limit = MAX_DELTA_CHARS) {
37
+ if (text.length <= limit)
38
+ return [text, ""];
39
+ let cut = limit;
40
+ const last = text.charCodeAt(cut - 1);
41
+ if (last >= 0xd800 && last <= 0xdbff)
42
+ cut -= 1;
43
+ return [text.slice(0, cut), text.slice(cut)];
44
+ }
45
+ /** A 4xx answer (other than 408/429) means this runtime no longer holds the turn. */
46
+ function lostAuthority(error) {
47
+ const status = httpStatus(error);
48
+ return status !== null && status >= 400 && status < 500 && status !== 429 && status !== 408;
49
+ }
50
+ /** HTTP status carried by a NavarchApiError, read structurally so tests can throw lookalikes. */
51
+ function httpStatus(error) {
52
+ const status = error?.status;
53
+ return typeof status === "number" ? status : null;
54
+ }
55
+ function verdict(status) {
56
+ if (status === "cancelled")
57
+ return "cancelled";
58
+ if (status === "expired_lease")
59
+ return "expired";
60
+ return status === "failed" ? "failed" : "lost";
61
+ }
62
+ /**
63
+ * Executes one conversation turn on this machine's selected adapter.
64
+ *
65
+ * - The claim token is journaled before the claim, and `started` is journaled
66
+ * before the agent is launched, so a turn id never launches twice.
67
+ * - Every append is journaled before it is sent and resent identically until
68
+ * acknowledged. Appends are serial, so event order equals send order.
69
+ * - Text is sent as it is parsed from the live agent stream. The first text
70
+ * goes out at once; later text is coalesced behind the in-flight append and a
71
+ * short gap so one answer cannot exhaust the 512-event conversation cap.
72
+ * - A renewal, append acknowledgement or fencing error showing that the turn is
73
+ * no longer running kills the process this runner launched, and only that one.
74
+ */
75
+ async function runConversationTurn(deps, conversationId, turnId) {
76
+ const log = deps.log ?? (0, logger_cjs_1.createLogger)("navi");
77
+ const now = deps.now ?? Date.now;
78
+ const newId = deps.newId ?? node_crypto_1.randomUUID;
79
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
80
+ const command = (body) => deps.api.conversationCommand(deps.machineId, conversationId, body);
81
+ const { journal } = await deps.journal.open(conversationId, turnId, newId());
82
+ if (journal.state === "done")
83
+ return { outcome: "already_done", ...EMPTY };
84
+ // `claimed` and `started` records belong to an earlier process or run. The
85
+ // recovery sweep settles them; they are never launched again.
86
+ if (journal.state !== "claiming")
87
+ return { outcome: "deferred", ...EMPTY };
88
+ let claim;
89
+ const claimSentAt = Date.now();
90
+ try {
91
+ claim = await command({ op: "claim", turnId: journal.turnId, token: journal.token });
92
+ }
93
+ catch (error) {
94
+ if (lostAuthority(error)) {
95
+ await deps.journal.finish(journal, "lost");
96
+ return { outcome: "lost", ...EMPTY };
97
+ }
98
+ throw error; // transport or 5xx: the journal stays `claiming` and the same token is retried
99
+ }
100
+ if (claim.status !== "running") {
101
+ const result = verdict(claim.status);
102
+ await deps.journal.finish(journal, result);
103
+ return { outcome: result, ...EMPTY };
104
+ }
105
+ journal.state = "claimed";
106
+ await deps.journal.write(journal);
107
+ const controller = new AbortController();
108
+ const onShutdown = () => controller.abort();
109
+ deps.signal?.addEventListener("abort", onShutdown, { once: true });
110
+ if (deps.signal?.aborted)
111
+ controller.abort();
112
+ // Mutable run state lives in one object so closures and the main flow agree on it.
113
+ const run = {
114
+ lost: null,
115
+ finished: false,
116
+ nextIndex: journal.acked,
117
+ textAppends: 0,
118
+ toolStatuses: 0,
119
+ launchedAt: 0,
120
+ firstTextAckedMs: null,
121
+ pendingText: "",
122
+ flushing: null,
123
+ finalizing: false,
124
+ lastAppendAt: 0,
125
+ agentFailed: false,
126
+ };
127
+ const leaseMs = deps.leaseMs ?? LEASE_MS;
128
+ let leaseTimer;
129
+ let renewTimer;
130
+ let gapTimer;
131
+ const lose = (reason) => {
132
+ if (run.lost)
133
+ return;
134
+ run.lost = reason;
135
+ controller.abort();
136
+ };
137
+ /**
138
+ * The agent stops when the lease can no longer be renewed in time. The
139
+ * deadline is local: `leaseMs` after the moment this runtime sent the request
140
+ * the server just acknowledged, which never outlasts the server's own lease.
141
+ * The server's absolute `leaseUntil` is deliberately not compared with the
142
+ * local clock, so a skewed runtime clock cannot kill a healthy turn.
143
+ */
144
+ const armLease = (sentAt) => {
145
+ if (run.finished)
146
+ return;
147
+ clearTimeout(leaseTimer);
148
+ leaseTimer = setTimeout(() => lose("expired"), Math.max(0, sentAt + leaseMs - Date.now()));
149
+ };
150
+ /** True while the server still reports this turn as running. */
151
+ const absorb = (result, sentAt) => {
152
+ if (result.status !== "running")
153
+ return false;
154
+ armLease(sentAt);
155
+ return true;
156
+ };
157
+ /** Journals, then sends, one event; resends the identical event on a transient failure. */
158
+ const send = async (text, terminal) => {
159
+ const event = { id: newId(), index: run.nextIndex + 1, text, ...(terminal ? { terminal } : {}) };
160
+ run.nextIndex = event.index;
161
+ journal.pending = event;
162
+ await deps.journal.write(journal);
163
+ const firstAttemptAt = Date.now();
164
+ for (let attempt = 1;; attempt += 1) {
165
+ const sentAt = Date.now();
166
+ try {
167
+ const result = await command({
168
+ op: "append", turnId: journal.turnId, token: journal.token,
169
+ eventId: event.id, index: event.index, text: event.text, ...(terminal ? { terminal } : {}),
170
+ });
171
+ journal.acked = event.index;
172
+ delete journal.pending;
173
+ await deps.journal.write(journal);
174
+ if (!terminal && !absorb(result, sentAt))
175
+ lose(verdict(result.status));
176
+ return result;
177
+ }
178
+ catch (error) {
179
+ if (lostAuthority(error)) {
180
+ lose("lost");
181
+ return null;
182
+ }
183
+ // A text append is retried through an outage for up to one lease length
184
+ // (the lease timer also aborts the turn); a terminal gets a few tries.
185
+ // Renewals can keep succeeding while appends fail, so the append retry
186
+ // has its own bound.
187
+ const stuck = controller.signal.aborted || Date.now() - firstAttemptAt >= leaseMs;
188
+ if (terminal ? attempt >= TERMINAL_ATTEMPTS : stuck) {
189
+ lose("lost");
190
+ return null;
191
+ }
192
+ await sleep(Math.min(MAX_RETRY_DELAY_MS, (deps.retryDelayMs ?? 250) * 2 ** Math.min(attempt - 1, 6)));
193
+ }
194
+ }
195
+ };
196
+ const flush = () => {
197
+ if (run.flushing || run.lost || run.finalizing || !run.pendingText)
198
+ return;
199
+ const budgetLeft = run.textAppends < MAX_TEXT_APPENDS;
200
+ // The gap doubles every four appends, so 20 appends cover a long answer
201
+ // (250 ms, 500 ms, 1 s, 2 s, 4 s) instead of bursting through the budget.
202
+ const baseGap = deps.appendGapMs ?? 250;
203
+ const gap = run.textAppends === 0 ? 0 : baseGap * 2 ** Math.floor(run.textAppends / 4);
204
+ const wait = run.lastAppendAt + gap - now();
205
+ const sizeReady = run.pendingText.length >= SIZE_FLUSH_CHARS;
206
+ if ((!budgetLeft && !sizeReady) || (wait > 0 && !sizeReady)) {
207
+ if (budgetLeft && !gapTimer)
208
+ gapTimer = setTimeout(() => { gapTimer = undefined; flush(); }, wait);
209
+ return;
210
+ }
211
+ const [chunk, rest] = takeChunk(run.pendingText);
212
+ run.pendingText = rest;
213
+ run.textAppends += 1;
214
+ run.flushing = send(chunk)
215
+ .then((result) => {
216
+ if (result && run.firstTextAckedMs === null)
217
+ run.firstTextAckedMs = node_perf_hooks_1.performance.now() - run.launchedAt;
218
+ })
219
+ .catch((error) => {
220
+ log.warn(`conversation ${conversationId} turn ${turnId} append failed: ${String(error).slice(0, 300)}`);
221
+ lose("lost");
222
+ })
223
+ .finally(() => {
224
+ run.flushing = null;
225
+ run.lastAppendAt = now();
226
+ flush();
227
+ });
228
+ };
229
+ const onEvent = (event) => {
230
+ if (run.lost)
231
+ return;
232
+ if (event.type === "text") {
233
+ run.pendingText += sanitizeConversationText(event.text);
234
+ flush();
235
+ }
236
+ else if (event.type === "tool_status") {
237
+ // Never mixed into assistant text. Transport v1 carries only public
238
+ // text and a terminal state, so tool progress stays on this machine.
239
+ run.toolStatuses += 1;
240
+ }
241
+ else {
242
+ run.agentFailed = true;
243
+ }
244
+ };
245
+ const scheduleRenew = () => {
246
+ renewTimer = setTimeout(async () => {
247
+ if (run.lost || run.finished || controller.signal.aborted)
248
+ return;
249
+ const sentAt = Date.now();
250
+ try {
251
+ const result = await command({ op: "renew", turnId: journal.turnId, token: journal.token });
252
+ if (run.finished)
253
+ return;
254
+ if (!absorb(result, sentAt)) {
255
+ if (!run.finalizing)
256
+ lose(verdict(result.status));
257
+ return;
258
+ }
259
+ }
260
+ catch (error) {
261
+ if (run.finished)
262
+ return;
263
+ if (lostAuthority(error)) {
264
+ lose("lost");
265
+ return;
266
+ }
267
+ // Transient failure: keep going until the armed lease deadline fires.
268
+ }
269
+ scheduleRenew();
270
+ }, deps.renewIntervalMs ?? 20_000);
271
+ };
272
+ /** Sends the terminal event and returns what the control plane recorded, or null if unconfirmed. */
273
+ const sendTerminal = async (text, terminal) => {
274
+ const acked = await send(text, terminal);
275
+ if (!acked)
276
+ return null;
277
+ // The server acknowledges a late terminal (after stop, expiry or an output
278
+ // limit) with 200 and its own terminal status, which is what actually happened.
279
+ return acked.status === terminal ? terminal : verdict(acked.status);
280
+ };
281
+ let terminalAcked = null;
282
+ let outcome = "failed";
283
+ let metrics = { firstEventMs: null, firstTextMs: null };
284
+ try {
285
+ const repositoryUrl = (0, workspace_cjs_1.validRepositoryUrl)(claim.repository);
286
+ const snapshot = claim.conversation;
287
+ let prepared = null;
288
+ let prompt = "";
289
+ armLease(claimSentAt);
290
+ scheduleRenew();
291
+ try {
292
+ if (!repositoryUrl)
293
+ throw new Error("claim carries no usable repository URL");
294
+ if (!snapshot || snapshot.id !== conversationId)
295
+ throw new Error("claim carries no matching conversation");
296
+ prompt = (0, prompt_cjs_1.buildConversationPrompt)(snapshot, journal.turnId);
297
+ prepared = await deps.prepareWorkspace({ conversationId, repositoryUrl, signal: controller.signal });
298
+ }
299
+ catch (error) {
300
+ if (!run.lost)
301
+ log.warn(`conversation ${conversationId} turn ${turnId} could not start: ${String(error).slice(0, 300)}`);
302
+ }
303
+ if (prepared && !run.lost) {
304
+ // After this write a crash can never lead to a second launch of this turn id.
305
+ journal.state = "started";
306
+ await deps.journal.write(journal);
307
+ run.launchedAt = node_perf_hooks_1.performance.now();
308
+ const result = await deps.adapter.conversation.run({
309
+ prompt, bin: deps.bin, ...(deps.model ? { model: deps.model } : {}),
310
+ cwd: prepared.cwd, env: deps.env, settingsPath: prepared.settingsPath,
311
+ timeoutMs: deps.turnTimeoutMs, signal: controller.signal, onEvent,
312
+ });
313
+ metrics = { firstEventMs: result.firstEventMs, firstTextMs: result.firstTextMs };
314
+ journal.firstEventMs = result.firstEventMs;
315
+ journal.firstTextMs = result.firstTextMs;
316
+ if (!result.succeeded || run.agentFailed) {
317
+ log.warn(`conversation ${conversationId} turn ${turnId} agent ended without success (exit ${result.exitCode}, timedOut=${result.timedOut}, aborted=${result.aborted}): ${result.stderr.slice(-300)}`);
318
+ }
319
+ // No more events can arrive. Stop background flushing, let the in-flight
320
+ // append finish, then send the remainder serially.
321
+ run.finalizing = true;
322
+ clearTimeout(gapTimer);
323
+ await run.flushing;
324
+ if (!run.lost) {
325
+ const completed = result.succeeded && !run.agentFailed && !result.aborted;
326
+ while (run.pendingText.length > MAX_DELTA_CHARS && !run.lost) {
327
+ const [chunk, rest] = takeChunk(run.pendingText);
328
+ run.pendingText = rest;
329
+ await send(chunk);
330
+ }
331
+ if (!run.lost) {
332
+ const text = run.pendingText;
333
+ run.pendingText = "";
334
+ terminalAcked = await sendTerminal(text, completed ? "completed" : "failed");
335
+ }
336
+ }
337
+ }
338
+ else if (!run.lost) {
339
+ // Setup failed before any agent ran: a public failed terminal with no text.
340
+ terminalAcked = await sendTerminal("", "failed");
341
+ }
342
+ }
343
+ catch (error) {
344
+ log.warn(`conversation ${conversationId} turn ${turnId} aborted: ${String(error).slice(0, 300)}`);
345
+ lose("lost");
346
+ }
347
+ finally {
348
+ run.finished = true;
349
+ clearTimeout(leaseTimer);
350
+ clearTimeout(renewTimer);
351
+ clearTimeout(gapTimer);
352
+ deps.signal?.removeEventListener("abort", onShutdown);
353
+ }
354
+ // An acknowledged terminal is the control plane's verdict; a timer that fired
355
+ // while it was in flight must not rewrite it.
356
+ if (terminalAcked)
357
+ outcome = terminalAcked;
358
+ else if (run.lost)
359
+ outcome = run.lost;
360
+ else
361
+ outcome = "lost";
362
+ await deps.journal.finish(journal, outcome);
363
+ return { outcome, ...metrics, firstTextAckedMs: run.firstTextAckedMs, textAppends: run.textAppends, toolStatuses: run.toolStatuses };
364
+ }
365
+ /**
366
+ * Settles a journal this process did not finish (crash, kill, or shutdown
367
+ * while the agent ran). It never launches the agent. It resends the one event
368
+ * that may not have been acknowledged, then a public `failed` terminal, so the
369
+ * user sees a result instead of waiting for lease expiry. If the control plane
370
+ * no longer honors the token, the record is closed as lost.
371
+ */
372
+ async function recoverUnfinishedTurn(deps, journal) {
373
+ const now = deps.now ?? Date.now;
374
+ const newId = deps.newId ?? node_crypto_1.randomUUID;
375
+ const send = (event) => deps.api.conversationCommand(deps.machineId, journal.conversationId, {
376
+ op: "append", turnId: journal.turnId, token: journal.token,
377
+ eventId: event.id, index: event.index, text: event.text, ...(event.terminal ? { terminal: event.terminal } : {}),
378
+ });
379
+ try {
380
+ // `claiming` records never get here: the loop re-sends their claim instead.
381
+ let index = journal.acked;
382
+ let terminal = false;
383
+ if (journal.pending) {
384
+ await send(journal.pending);
385
+ index = journal.pending.index;
386
+ terminal = !!journal.pending.terminal;
387
+ journal.acked = index;
388
+ delete journal.pending;
389
+ }
390
+ if (!terminal)
391
+ await send({ id: newId(), index: index + 1, text: "", terminal: "failed" });
392
+ await deps.journal.finish(journal, "failed");
393
+ return "settled";
394
+ }
395
+ catch (error) {
396
+ if (lostAuthority(error) || now() - journal.updatedAt > RECOVERY_GIVE_UP_MS) {
397
+ await deps.journal.finish(journal, "lost");
398
+ return "settled";
399
+ }
400
+ return "retry";
401
+ }
402
+ }
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.conversationEnv = conversationEnv;
7
+ exports.validRepositoryUrl = validRepositoryUrl;
8
+ exports.conversationRoot = conversationRoot;
9
+ exports.prepareConversationWorkspace = prepareConversationWorkspace;
10
+ exports.removeConversationWorkspace = removeConversationWorkspace;
11
+ const node_fs_1 = require("node:fs");
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const sandbox_cjs_1 = require("../sandbox.cjs");
14
+ const journal_cjs_1 = require("./journal.cjs");
15
+ const CLONE_TIMEOUT_MS = 3 * 60_000;
16
+ const REPOSITORY_MARKER = "repository-url";
17
+ /** Variables that carry this runtime's own or a task's credentials, never a conversation's. */
18
+ const WITHHELD_ENV = /^(NAVARCH_|GH_|GITHUB_|GIT_ASKPASS$|SSH_ASKPASS$|SSH_AUTH_SOCK$)/;
19
+ /**
20
+ * Environment for a conversation child. It inherits the machine's own login
21
+ * (HOME, PATH, provider keys the local agent needs) but not the Navarch
22
+ * machine token, broker or GitHub credentials the runtime holds for tasks.
23
+ */
24
+ function conversationEnv(base = process.env) {
25
+ const env = {};
26
+ for (const [key, value] of Object.entries(base)) {
27
+ if (value === undefined || WITHHELD_ENV.test(key))
28
+ continue;
29
+ env[key] = value;
30
+ }
31
+ return env;
32
+ }
33
+ /**
34
+ * Maps the project's stored repository value to a GitHub https clone URL.
35
+ * The control plane stores a free-form value; the task path accepts
36
+ * `owner/name`, `git@github.com:owner/name(.git)` and `https://github.com/...`
37
+ * (lib/navarch/github-repository.ts), and so does this one. Anything else
38
+ * (another host, `ext::`, `file:`, `-` options, embedded credentials) returns
39
+ * null, so the runtime never hands an unvalidated string to git.
40
+ */
41
+ function validRepositoryUrl(value) {
42
+ if (typeof value !== "string" || value.length > 2048 || /[\s\0]/.test(value))
43
+ return null;
44
+ const fullName = value
45
+ .replace(/^git@github\.com:/i, "")
46
+ .replace(/^https:\/\/github\.com\//i, "")
47
+ .replace(/\.git$/i, "")
48
+ .replace(/\/+$/, "");
49
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(fullName) || fullName.startsWith("-"))
50
+ return null;
51
+ return `https://github.com/${fullName}.git`;
52
+ }
53
+ function conversationRoot(workspaceRoot, conversationId) {
54
+ return node_path_1.default.join(workspaceRoot, "conversations", (0, journal_cjs_1.assertUuid)(conversationId, "conversation id"));
55
+ }
56
+ /**
57
+ * Prepares the conversation's own checkout. Each conversation gets a separate
58
+ * directory, so two conversations never share files, history or git config.
59
+ * The first turn clones; later turns of the same conversation reuse the
60
+ * checkout so a follow-up sees the same revision as the first answer.
61
+ *
62
+ * The clone uses the machine's own git credentials (the same ones a local
63
+ * `git clone` would use), never a Navarch server-side provider credential.
64
+ * A repository this machine cannot read fails the turn instead of falling back.
65
+ */
66
+ async function prepareConversationWorkspace(options) {
67
+ const url = validRepositoryUrl(options.repositoryUrl);
68
+ if (!url)
69
+ throw new Error("repository URL is not a plain https URL");
70
+ const runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
71
+ const root = conversationRoot(options.workspaceRoot, options.conversationId);
72
+ const workspace = {
73
+ root,
74
+ checkout: node_path_1.default.join(root, "repo"),
75
+ metaDir: node_path_1.default.join(root, "meta"),
76
+ };
77
+ await node_fs_1.promises.mkdir(workspace.metaDir, { recursive: true, mode: 0o700 });
78
+ const marker = node_path_1.default.join(root, REPOSITORY_MARKER);
79
+ const recorded = await node_fs_1.promises.readFile(marker, "utf8").catch(() => null);
80
+ const hasCheckout = await node_fs_1.promises.stat(node_path_1.default.join(workspace.checkout, ".git")).then(() => true, () => false);
81
+ if (hasCheckout && recorded === url)
82
+ return workspace;
83
+ // New conversation, a changed repository URL, or an interrupted clone.
84
+ await node_fs_1.promises.rm(workspace.checkout, { recursive: true, force: true });
85
+ const env = {
86
+ ...options.env,
87
+ GIT_TERMINAL_PROMPT: "0",
88
+ GIT_ALLOW_PROTOCOL: "https",
89
+ GIT_CONFIG_COUNT: "1",
90
+ GIT_CONFIG_KEY_0: "core.hooksPath",
91
+ GIT_CONFIG_VALUE_0: process.platform === "win32" ? "NUL" : "/dev/null",
92
+ };
93
+ const result = await runner.run("git", ["clone", "--quiet", "--filter=blob:none", "--no-tags", "--", url, workspace.checkout], { cwd: root, env, timeoutMs: CLONE_TIMEOUT_MS, signal: options.signal });
94
+ if (result.code !== 0) {
95
+ await node_fs_1.promises.rm(workspace.checkout, { recursive: true, force: true }).catch(() => undefined);
96
+ throw new Error(`git clone failed with exit ${result.code}: ${result.stderr.slice(-500)}`);
97
+ }
98
+ await node_fs_1.promises.writeFile(marker, url, { encoding: "utf8", mode: 0o600 });
99
+ return workspace;
100
+ }
101
+ async function removeConversationWorkspace(workspaceRoot, conversationId) {
102
+ await node_fs_1.promises.rm(conversationRoot(workspaceRoot, conversationId), { recursive: true, force: true });
103
+ }