@astroanywhere/agent 0.4.4 → 0.5.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/dist/lib/branch-lock.d.ts +3 -3
- package/dist/lib/branch-lock.js +3 -3
- package/dist/lib/git-pr.d.ts +11 -11
- package/dist/lib/git-pr.d.ts.map +1 -1
- package/dist/lib/git-pr.js +17 -17
- package/dist/lib/git-pr.js.map +1 -1
- package/dist/lib/local-merge.d.ts +3 -3
- package/dist/lib/local-merge.d.ts.map +1 -1
- package/dist/lib/local-merge.js +9 -9
- package/dist/lib/local-merge.js.map +1 -1
- package/dist/lib/task-executor.d.ts +2 -0
- package/dist/lib/task-executor.d.ts.map +1 -1
- package/dist/lib/task-executor.js +108 -62
- package/dist/lib/task-executor.js.map +1 -1
- package/dist/lib/worktree.d.ts +27 -22
- package/dist/lib/worktree.d.ts.map +1 -1
- package/dist/lib/worktree.js +200 -116
- package/dist/lib/worktree.js.map +1 -1
- package/dist/types.d.ts +11 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/lib/worktree.js
CHANGED
|
@@ -10,7 +10,7 @@ import { repoHasRemote } from './workdir-safety.js';
|
|
|
10
10
|
import { pushBranchToRemote } from './git-pr.js';
|
|
11
11
|
const execFileAsync = promisify(execFile);
|
|
12
12
|
export async function createWorktree(options) {
|
|
13
|
-
const { workingDirectory, taskId, rootOverride, projectId, nodeId, shortProjectId, shortNodeId, agentDir, baseBranch: dispatchBaseBranch,
|
|
13
|
+
const { workingDirectory, taskId, rootOverride, projectId, nodeId, shortProjectId, shortNodeId, agentDir, baseBranch: dispatchBaseBranch, deliveryBranch: dispatchDeliveryBranch, deliveryBranchIsSingleton, stdout, stderr, signal, operational, } = options;
|
|
14
14
|
// Validate taskId format to prevent command injection
|
|
15
15
|
validateTaskId(taskId);
|
|
16
16
|
const resolvedWorkingDirectory = resolve(workingDirectory);
|
|
@@ -27,15 +27,29 @@ export async function createWorktree(options) {
|
|
|
27
27
|
const agentDirName = agentDir ?? '.astro';
|
|
28
28
|
const baseRoot = rootOverride ?? await resolveWorktreeRoot(gitRoot, agentDirName);
|
|
29
29
|
const branchPrefix = await readBranchPrefix(gitRoot, agentDirName);
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
30
|
+
// Delivery branch: per-connected-component accumulation branch (e.g., 'astro/7b19a9-e4f1a2').
|
|
31
|
+
// Task branch: per-task worktree branch (e.g., 'astro/7b19a9-a1b2c3').
|
|
32
|
+
// Singleton mode: delivery branch IS the working branch (no task sub-branch).
|
|
33
|
+
//
|
|
34
|
+
// Validate dispatch-provided branch name: while execFileAsync prevents shell
|
|
35
|
+
// injection, we still reject names with unexpected characters to prevent
|
|
36
|
+
// path traversal or git ref manipulation.
|
|
37
|
+
if (dispatchDeliveryBranch) {
|
|
38
|
+
validateBranchName(dispatchDeliveryBranch);
|
|
39
|
+
}
|
|
40
|
+
const deliveryBranchName = dispatchDeliveryBranch
|
|
34
41
|
?? (shortProjectId ? `${branchPrefix}${sanitize(shortProjectId)}` : undefined);
|
|
42
|
+
// Validate singleton prerequisite before computing isSingleton
|
|
43
|
+
if (deliveryBranchIsSingleton && !deliveryBranchName) {
|
|
44
|
+
throw new Error('Singleton delivery branch requires deliveryBranchName');
|
|
45
|
+
}
|
|
46
|
+
const isSingleton = deliveryBranchIsSingleton && !!deliveryBranchName;
|
|
35
47
|
const branchSuffix = shortProjectId && shortNodeId
|
|
36
48
|
? `${sanitize(shortProjectId)}-${sanitize(shortNodeId)}`
|
|
37
49
|
: sanitize(taskId);
|
|
38
|
-
const taskBranchName =
|
|
50
|
+
const taskBranchName = isSingleton
|
|
51
|
+
? deliveryBranchName // Singleton: work directly on delivery branch
|
|
52
|
+
: `${branchPrefix}${branchSuffix}`;
|
|
39
53
|
const worktreePath = join(baseRoot, branchSuffix);
|
|
40
54
|
// Abort-signal gate: check between every long git operation so cancellation
|
|
41
55
|
// actually halts workspace prep instead of letting it run to completion.
|
|
@@ -47,15 +61,19 @@ export async function createWorktree(options) {
|
|
|
47
61
|
checkAborted();
|
|
48
62
|
await rm(worktreePath, { recursive: true, force: true });
|
|
49
63
|
await pruneWorktrees(gitRoot);
|
|
50
|
-
// Clean up lingering worktrees
|
|
51
|
-
//
|
|
64
|
+
// Clean up lingering worktrees for the working branch.
|
|
65
|
+
// For singletons, only clean up stale worktree checkouts — never delete
|
|
66
|
+
// the delivery branch itself (it's managed by the server).
|
|
67
|
+
// For multi-task, also delete the stale task branch and its remote.
|
|
52
68
|
checkAborted();
|
|
53
69
|
await removeLingeringWorktrees(gitRoot, taskBranchName);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
if (!isSingleton) {
|
|
71
|
+
await ensureBranchAvailable(gitRoot, taskBranchName);
|
|
72
|
+
// Delete remote task branch if it exists — prevents non-fast-forward push
|
|
73
|
+
// failures when re-executing a task whose previous branch was already pushed
|
|
74
|
+
checkAborted();
|
|
75
|
+
await deleteRemoteBranch(gitRoot, taskBranchName);
|
|
76
|
+
}
|
|
59
77
|
// Fetch latest so we branch from up-to-date origin (skip for local-only repos)
|
|
60
78
|
checkAborted();
|
|
61
79
|
const hasRemote = await repoHasRemote(gitRoot);
|
|
@@ -81,23 +99,24 @@ export async function createWorktree(options) {
|
|
|
81
99
|
const defaultBranch = (dispatchBranchValid ? dispatchBaseBranch : null)
|
|
82
100
|
?? await readBaseBranch(gitRoot, agentDirName)
|
|
83
101
|
?? await getDefaultBranch(gitRoot);
|
|
84
|
-
// Ensure the
|
|
102
|
+
// Ensure the delivery branch exists on origin. If this is the first task,
|
|
85
103
|
// create it from origin/{defaultBranch}. Idempotent.
|
|
86
104
|
checkAborted();
|
|
87
|
-
if (
|
|
88
|
-
await
|
|
105
|
+
if (deliveryBranchName) {
|
|
106
|
+
await ensureDeliveryBranch(gitRoot, deliveryBranchName, defaultBranch, operational);
|
|
89
107
|
}
|
|
90
|
-
// Create persistent
|
|
108
|
+
// Create persistent delivery worktree (detached HEAD) — idempotent.
|
|
91
109
|
// This enables file browsing at .astro/worktrees/{shortProjectId}/ after
|
|
92
110
|
// task worktrees are cleaned up.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
// Skip for singletons — the task worktree IS the working worktree.
|
|
112
|
+
let deliveryWorktreePath;
|
|
113
|
+
if (!isSingleton && deliveryBranchName && shortProjectId) {
|
|
114
|
+
deliveryWorktreePath = await createDeliveryWorktree(gitRoot, deliveryBranchName, baseRoot, sanitize(shortProjectId), operational) ?? undefined;
|
|
96
115
|
}
|
|
97
|
-
// Start point: prefer
|
|
98
|
-
// fall back to default branch for non-
|
|
116
|
+
// Start point: prefer delivery branch tip (accumulates prior task work),
|
|
117
|
+
// fall back to default branch for non-delivery worktrees.
|
|
99
118
|
// Using origin/<branch> avoids stale local refs.
|
|
100
|
-
const effectiveBase =
|
|
119
|
+
const effectiveBase = deliveryBranchName ?? defaultBranch;
|
|
101
120
|
const remoteRef = `origin/${effectiveBase}`;
|
|
102
121
|
const hasRemoteRef = await refExists(gitRoot, remoteRef);
|
|
103
122
|
const startPoint = hasRemoteRef ? remoteRef : effectiveBase;
|
|
@@ -118,28 +137,66 @@ export async function createWorktree(options) {
|
|
|
118
137
|
console.warn('[worktree] Failed to capture commitBeforeSha for audit trail');
|
|
119
138
|
}
|
|
120
139
|
checkAborted();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
if (isSingleton) {
|
|
141
|
+
// Singleton: checkout the existing delivery branch in the worktree.
|
|
142
|
+
// No new branch is created — the agent works directly on the delivery branch.
|
|
143
|
+
// INVARIANT: The server guarantees at most one task dispatched per singleton
|
|
144
|
+
// delivery branch. Concurrent dispatches to the same branch are prevented
|
|
145
|
+
// by the dispatch engine's node-level locking.
|
|
146
|
+
operational?.(`Creating singleton worktree on delivery branch ${taskBranchName}...`, 'astro');
|
|
147
|
+
try {
|
|
148
|
+
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', worktreePath, taskBranchName], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
152
|
+
if (msg.includes('already checked out') || msg.includes('already registered')) {
|
|
153
|
+
// Stale worktree — prune and retry. If the branch is still locked after
|
|
154
|
+
// pruning, another task is genuinely using it (server invariant violated).
|
|
155
|
+
console.warn(`[worktree] Delivery branch ${taskBranchName} locked by existing worktree, pruning stale entries`);
|
|
156
|
+
await pruneWorktrees(gitRoot);
|
|
157
|
+
try {
|
|
158
|
+
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', worktreePath, taskBranchName], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
|
|
159
|
+
}
|
|
160
|
+
catch (retryErr) {
|
|
161
|
+
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
162
|
+
if (retryMsg.includes('already checked out') || retryMsg.includes('already registered')) {
|
|
163
|
+
throw new Error(`Singleton delivery branch ${taskBranchName} is still checked out after pruning. ` +
|
|
164
|
+
`Another task may be actively using this branch — server-side mutual exclusion may be broken. ` +
|
|
165
|
+
`Original error: ${retryMsg}`);
|
|
166
|
+
}
|
|
167
|
+
throw retryErr;
|
|
168
|
+
}
|
|
132
169
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'prune'], { env: withGitEnv(), timeout: 5_000 });
|
|
136
|
-
await execFileAsync('git', ['-C', gitRoot, 'branch', '-D', taskBranchName], { env: withGitEnv(), timeout: 5_000 });
|
|
170
|
+
else {
|
|
171
|
+
throw err;
|
|
137
172
|
}
|
|
138
|
-
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
// Multi-task: create a new task branch from the delivery branch (or default branch).
|
|
177
|
+
operational?.(`Creating worktree from ${startPoint}...`, 'astro');
|
|
178
|
+
try {
|
|
139
179
|
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '-b', taskBranchName, worktreePath, startPoint], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
|
|
140
180
|
}
|
|
141
|
-
|
|
142
|
-
|
|
181
|
+
catch (err) {
|
|
182
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
183
|
+
if (msg.includes('already exists')) {
|
|
184
|
+
// Branch exists from a previous failed/retried execution — delete it and retry.
|
|
185
|
+
console.log(`[worktree] Branch ${taskBranchName} already exists, deleting stale branch and retrying`);
|
|
186
|
+
try {
|
|
187
|
+
await execFileAsync('git', ['-C', gitRoot, 'branch', '-D', taskBranchName], { env: withGitEnv(), timeout: 5_000 });
|
|
188
|
+
}
|
|
189
|
+
catch { /* branch might be checked out in a stale worktree — prune first */ }
|
|
190
|
+
try {
|
|
191
|
+
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'prune'], { env: withGitEnv(), timeout: 5_000 });
|
|
192
|
+
await execFileAsync('git', ['-C', gitRoot, 'branch', '-D', taskBranchName], { env: withGitEnv(), timeout: 5_000 });
|
|
193
|
+
}
|
|
194
|
+
catch { /* best effort — may already be deleted */ }
|
|
195
|
+
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '-b', taskBranchName, worktreePath, startPoint], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
143
200
|
}
|
|
144
201
|
}
|
|
145
202
|
// Initialize submodules if the repo uses them (non-fatal)
|
|
@@ -210,13 +267,17 @@ export async function createWorktree(options) {
|
|
|
210
267
|
return {
|
|
211
268
|
workingDirectory: worktreeWorkingDirectory,
|
|
212
269
|
branchName: taskBranchName,
|
|
213
|
-
|
|
270
|
+
// Singleton: PR targets the base branch (main) directly.
|
|
271
|
+
// Multi-task: PR targets the delivery branch (effectiveBase).
|
|
272
|
+
baseBranch: isSingleton ? defaultBranch : effectiveBase,
|
|
214
273
|
commitBeforeSha,
|
|
215
274
|
gitRoot,
|
|
216
|
-
|
|
217
|
-
|
|
275
|
+
// Singleton: no accumulative merge step — PR goes directly to base branch.
|
|
276
|
+
deliveryBranch: isSingleton ? undefined : deliveryBranchName,
|
|
277
|
+
deliveryWorktreePath: isSingleton ? undefined : deliveryWorktreePath,
|
|
218
278
|
cleanup: async (options) => {
|
|
219
|
-
|
|
279
|
+
// Singleton: always keep the delivery branch (server manages its lifecycle).
|
|
280
|
+
await cleanupWorktree(gitRoot, worktreePath, taskBranchName, isSingleton || options?.keepBranch);
|
|
220
281
|
},
|
|
221
282
|
};
|
|
222
283
|
}
|
|
@@ -269,7 +330,7 @@ export async function removeLingeringWorktrees(gitRoot, branchName) {
|
|
|
269
330
|
}
|
|
270
331
|
}
|
|
271
332
|
/**
|
|
272
|
-
* Ensure the
|
|
333
|
+
* Ensure the delivery branch exists.
|
|
273
334
|
*
|
|
274
335
|
* For repos WITH a remote: create on origin and push.
|
|
275
336
|
* For repos WITHOUT a remote: create locally only (no push).
|
|
@@ -281,185 +342,185 @@ export async function removeLingeringWorktrees(gitRoot, branchName) {
|
|
|
281
342
|
* It is internally idempotent: if a parallel task already created the branch,
|
|
282
343
|
* the "already exists" error is caught and handled gracefully.
|
|
283
344
|
*/
|
|
284
|
-
async function
|
|
345
|
+
async function ensureDeliveryBranch(gitRoot, deliveryBranch, defaultBranch, operational) {
|
|
285
346
|
const hasRemote = await repoHasRemote(gitRoot);
|
|
286
347
|
if (hasRemote) {
|
|
287
348
|
// --- Remote mode: check origin, push if needed ---
|
|
288
|
-
const remoteRef = `origin/${
|
|
349
|
+
const remoteRef = `origin/${deliveryBranch}`;
|
|
289
350
|
if (await refExists(gitRoot, remoteRef)) {
|
|
290
|
-
operational?.(`
|
|
291
|
-
console.log(`[worktree]
|
|
351
|
+
operational?.(`Delivery branch ${deliveryBranch} exists on origin`, 'git');
|
|
352
|
+
console.log(`[worktree] Delivery branch ${deliveryBranch} exists on origin`);
|
|
292
353
|
return;
|
|
293
354
|
}
|
|
294
355
|
// Branch not on origin — either create it or push an existing local branch.
|
|
295
|
-
const localExists = await refExists(gitRoot, `refs/heads/${
|
|
356
|
+
const localExists = await refExists(gitRoot, `refs/heads/${deliveryBranch}`);
|
|
296
357
|
if (!localExists) {
|
|
297
|
-
// First-ever task for this
|
|
358
|
+
// First-ever task for this component — create the branch locally.
|
|
298
359
|
const defaultRemoteRef = `origin/${defaultBranch}`;
|
|
299
360
|
const hasDefaultRemote = await refExists(gitRoot, defaultRemoteRef);
|
|
300
361
|
const startPoint = hasDefaultRemote ? defaultRemoteRef : defaultBranch;
|
|
301
|
-
operational?.(`Creating
|
|
302
|
-
console.log(`[worktree] Creating
|
|
362
|
+
operational?.(`Creating delivery branch ${deliveryBranch} from ${startPoint}...`, 'git');
|
|
363
|
+
console.log(`[worktree] Creating delivery branch ${deliveryBranch} from ${startPoint}`);
|
|
303
364
|
try {
|
|
304
|
-
await execFileAsync('git', ['-C', gitRoot, 'branch',
|
|
365
|
+
await execFileAsync('git', ['-C', gitRoot, 'branch', deliveryBranch, startPoint], { env: withGitEnv(), timeout: 10_000 });
|
|
305
366
|
}
|
|
306
367
|
catch (err) {
|
|
307
368
|
const msg = err instanceof Error ? err.message : String(err);
|
|
308
369
|
// Handle race condition: parallel task already created the branch locally.
|
|
309
370
|
if (msg.includes('already exists')) {
|
|
310
|
-
operational?.(`
|
|
311
|
-
console.log(`[worktree]
|
|
371
|
+
operational?.(`Delivery branch ${deliveryBranch} already created (race OK)`, 'git');
|
|
372
|
+
console.log(`[worktree] Delivery branch ${deliveryBranch} created by another task (race OK)`);
|
|
312
373
|
}
|
|
313
374
|
else {
|
|
314
|
-
throw new Error(`Failed to create
|
|
375
|
+
throw new Error(`Failed to create delivery branch ${deliveryBranch}: ${msg}`);
|
|
315
376
|
}
|
|
316
377
|
}
|
|
317
378
|
}
|
|
318
379
|
else {
|
|
319
|
-
operational?.(`
|
|
320
|
-
console.log(`[worktree]
|
|
380
|
+
operational?.(`Delivery branch ${deliveryBranch} exists locally but not on origin`, 'git');
|
|
381
|
+
console.log(`[worktree] Delivery branch ${deliveryBranch} exists locally, not on origin — pushing`);
|
|
321
382
|
}
|
|
322
|
-
// Push the
|
|
383
|
+
// Push the delivery branch to origin — required for PR mode to work.
|
|
323
384
|
// Uses shared helper: 2-attempt retry with 2s delay + post-push verification.
|
|
324
|
-
const pushResult = await pushBranchToRemote(gitRoot,
|
|
385
|
+
const pushResult = await pushBranchToRemote(gitRoot, deliveryBranch, {
|
|
325
386
|
operational,
|
|
326
|
-
label: '
|
|
387
|
+
label: 'ensureDeliveryBranch',
|
|
327
388
|
});
|
|
328
389
|
if (!pushResult.ok) {
|
|
329
390
|
operational?.('PR delivery will fail — the base branch must exist on GitHub for PRs.', 'git');
|
|
330
|
-
throw new Error(`${pushResult.error}. PR delivery requires the
|
|
391
|
+
throw new Error(`${pushResult.error}. PR delivery requires the delivery branch to exist on the remote. `
|
|
331
392
|
+ `Check: git remote permissions, SSH keys, network connectivity.`);
|
|
332
393
|
}
|
|
333
394
|
}
|
|
334
395
|
else {
|
|
335
396
|
// --- Local mode (no remote): create branch locally only ---
|
|
336
|
-
if (await refExists(gitRoot, `refs/heads/${
|
|
337
|
-
operational?.(`
|
|
338
|
-
console.log(`[worktree]
|
|
397
|
+
if (await refExists(gitRoot, `refs/heads/${deliveryBranch}`)) {
|
|
398
|
+
operational?.(`Delivery branch ${deliveryBranch} exists locally (no remote)`, 'git');
|
|
399
|
+
console.log(`[worktree] Delivery branch ${deliveryBranch} exists locally (no remote)`);
|
|
339
400
|
return;
|
|
340
401
|
}
|
|
341
|
-
operational?.(`Creating local
|
|
342
|
-
console.log(`[worktree] Creating local
|
|
402
|
+
operational?.(`Creating local delivery branch ${deliveryBranch} from ${defaultBranch}...`, 'git');
|
|
403
|
+
console.log(`[worktree] Creating local delivery branch ${deliveryBranch} from ${defaultBranch}`);
|
|
343
404
|
try {
|
|
344
|
-
await execFileAsync('git', ['-C', gitRoot, 'branch',
|
|
345
|
-
operational?.(`Created local
|
|
346
|
-
console.log(`[worktree] Created local
|
|
405
|
+
await execFileAsync('git', ['-C', gitRoot, 'branch', deliveryBranch, defaultBranch], { env: withGitEnv(), timeout: 10_000 });
|
|
406
|
+
operational?.(`Created local delivery branch ${deliveryBranch}`, 'git');
|
|
407
|
+
console.log(`[worktree] Created local delivery branch ${deliveryBranch}`);
|
|
347
408
|
}
|
|
348
409
|
catch (err) {
|
|
349
410
|
const msg = err instanceof Error ? err.message : String(err);
|
|
350
411
|
if (msg.includes('already exists')) {
|
|
351
|
-
operational?.(`
|
|
352
|
-
console.log(`[worktree]
|
|
412
|
+
operational?.(`Delivery branch ${deliveryBranch} already created (race OK)`, 'git');
|
|
413
|
+
console.log(`[worktree] Delivery branch ${deliveryBranch} created by another task (race OK)`);
|
|
353
414
|
return;
|
|
354
415
|
}
|
|
355
|
-
throw new Error(`Failed to create local
|
|
416
|
+
throw new Error(`Failed to create local delivery branch ${deliveryBranch}: ${msg}`);
|
|
356
417
|
}
|
|
357
418
|
}
|
|
358
419
|
}
|
|
359
420
|
/**
|
|
360
|
-
* Create a persistent
|
|
421
|
+
* Create a persistent delivery worktree using detached HEAD.
|
|
361
422
|
*
|
|
362
|
-
* The
|
|
363
|
-
* the
|
|
364
|
-
* ref remains free for temporary merge worktrees (
|
|
365
|
-
* checks out the
|
|
423
|
+
* The delivery worktree lives at {baseRoot}/{shortProjectId}/ and mirrors
|
|
424
|
+
* the delivery branch on disk. It uses `--detach` so the delivery branch
|
|
425
|
+
* ref remains free for temporary merge worktrees (localMergeIntoDeliveryBranch
|
|
426
|
+
* checks out the delivery branch — git prevents the same branch in two worktrees).
|
|
366
427
|
*
|
|
367
428
|
* Idempotent — safe to call on every task dispatch. If the worktree
|
|
368
429
|
* already exists, returns the existing path.
|
|
369
430
|
*/
|
|
370
|
-
export async function
|
|
371
|
-
const
|
|
431
|
+
export async function createDeliveryWorktree(gitRoot, deliveryBranch, baseRoot, shortProjectId, operational) {
|
|
432
|
+
const deliveryWorktreePath = join(baseRoot, shortProjectId);
|
|
372
433
|
// Already exists — no-op
|
|
373
|
-
if (existsSync(
|
|
374
|
-
console.log(`[worktree]
|
|
375
|
-
return
|
|
434
|
+
if (existsSync(deliveryWorktreePath)) {
|
|
435
|
+
console.log(`[worktree] Delivery worktree already exists at ${deliveryWorktreePath}`);
|
|
436
|
+
return deliveryWorktreePath;
|
|
376
437
|
}
|
|
377
438
|
// Determine start point: prefer local ref, fall back to remote
|
|
378
|
-
const localRef = `refs/heads/${
|
|
379
|
-
const remoteRef = `origin/${
|
|
439
|
+
const localRef = `refs/heads/${deliveryBranch}`;
|
|
440
|
+
const remoteRef = `origin/${deliveryBranch}`;
|
|
380
441
|
const hasLocal = await refExists(gitRoot, localRef);
|
|
381
442
|
const startPoint = hasLocal ? localRef : remoteRef;
|
|
382
443
|
try {
|
|
383
|
-
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '--detach',
|
|
384
|
-
console.log(`[worktree] Created persistent
|
|
385
|
-
return
|
|
444
|
+
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '--detach', deliveryWorktreePath, startPoint], { env: withGitEnv(), timeout: 30_000 });
|
|
445
|
+
console.log(`[worktree] Created persistent delivery worktree at ${deliveryWorktreePath} (detached HEAD at ${deliveryBranch})`);
|
|
446
|
+
return deliveryWorktreePath;
|
|
386
447
|
}
|
|
387
448
|
catch (err) {
|
|
388
449
|
const msg = err instanceof Error ? err.message : String(err);
|
|
389
450
|
// Handle race: another parallel task created it between our check and git worktree add
|
|
390
451
|
if (msg.includes('already registered') || msg.includes('already exists')) {
|
|
391
|
-
console.log(`[worktree]
|
|
392
|
-
return
|
|
452
|
+
console.log(`[worktree] Delivery worktree created by another task (race OK): ${deliveryWorktreePath}`);
|
|
453
|
+
return deliveryWorktreePath;
|
|
393
454
|
}
|
|
394
|
-
console.warn(`[worktree] Failed to create
|
|
395
|
-
operational?.(`WARNING: Could not create
|
|
455
|
+
console.warn(`[worktree] Failed to create delivery worktree: ${msg}`);
|
|
456
|
+
operational?.(`WARNING: Could not create delivery worktree (file browsing between tasks may be unavailable): ${msg}`, 'git');
|
|
396
457
|
return null;
|
|
397
458
|
}
|
|
398
459
|
}
|
|
399
460
|
/**
|
|
400
|
-
* Sync the persistent
|
|
461
|
+
* Sync the persistent delivery worktree to the latest delivery branch tip.
|
|
401
462
|
*
|
|
402
|
-
* After each successful merge (branch or PR mode), the
|
|
403
|
-
* forward. This updates the detached HEAD in the
|
|
463
|
+
* After each successful merge (branch or PR mode), the delivery branch moves
|
|
464
|
+
* forward. This updates the detached HEAD in the delivery worktree so the
|
|
404
465
|
* files on disk reflect the latest state.
|
|
405
466
|
*
|
|
406
467
|
* Ref selection:
|
|
407
|
-
* - Branch mode (no remote):
|
|
408
|
-
* refs/heads/{
|
|
409
|
-
* - PR mode (has remote): GitHub merge advances origin/{
|
|
468
|
+
* - Branch mode (no remote): localMergeIntoDeliveryBranch() advances
|
|
469
|
+
* refs/heads/{deliveryBranch} directly → use the local ref.
|
|
470
|
+
* - PR mode (has remote): GitHub merge advances origin/{deliveryBranch},
|
|
410
471
|
* but refs/heads/ is stale (no local commit) → fetch then use remote ref.
|
|
411
472
|
*
|
|
412
473
|
* Non-fatal — sync failure doesn't affect task completion.
|
|
413
474
|
*/
|
|
414
|
-
export async function
|
|
415
|
-
if (!existsSync(
|
|
475
|
+
export async function syncDeliveryWorktree(deliveryWorktreePath, deliveryBranch, gitRoot) {
|
|
476
|
+
if (!existsSync(deliveryWorktreePath)) {
|
|
416
477
|
return;
|
|
417
478
|
}
|
|
418
479
|
// Determine the correct ref to checkout:
|
|
419
480
|
// - Remote repos (PR mode): fetch, then use origin/ (remote has the merged state)
|
|
420
481
|
// - Local repos (branch mode): use refs/heads/ (local merge updated it directly)
|
|
421
482
|
const hasRemote = await repoHasRemote(gitRoot);
|
|
422
|
-
let checkoutRef = `refs/heads/${
|
|
483
|
+
let checkoutRef = `refs/heads/${deliveryBranch}`;
|
|
423
484
|
if (hasRemote) {
|
|
424
485
|
try {
|
|
425
|
-
await execFileAsync('git', ['-C', gitRoot, 'fetch', 'origin',
|
|
426
|
-
checkoutRef = `origin/${
|
|
486
|
+
await execFileAsync('git', ['-C', gitRoot, 'fetch', 'origin', deliveryBranch], { env: withGitEnv(), timeout: 15_000 });
|
|
487
|
+
checkoutRef = `origin/${deliveryBranch}`;
|
|
427
488
|
}
|
|
428
489
|
catch (fetchErr) {
|
|
429
490
|
const fetchMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
430
|
-
console.warn(`[worktree]
|
|
491
|
+
console.warn(`[worktree] syncDeliveryWorktree: fetch origin/${deliveryBranch} failed: ${fetchMsg}`);
|
|
431
492
|
// Fall back to local ref (best effort)
|
|
432
493
|
}
|
|
433
494
|
}
|
|
434
495
|
try {
|
|
435
|
-
await execFileAsync('git', ['-C',
|
|
436
|
-
console.log(`[worktree] Synced
|
|
496
|
+
await execFileAsync('git', ['-C', deliveryWorktreePath, 'checkout', '--detach', checkoutRef], { env: withGitEnv(), timeout: 10_000 });
|
|
497
|
+
console.log(`[worktree] Synced delivery worktree at ${deliveryWorktreePath} to ${checkoutRef}`);
|
|
437
498
|
}
|
|
438
499
|
catch (err) {
|
|
439
500
|
const msg = err instanceof Error ? err.message : String(err);
|
|
440
|
-
console.warn(`[worktree] Failed to sync
|
|
501
|
+
console.warn(`[worktree] Failed to sync delivery worktree: ${msg}`);
|
|
441
502
|
}
|
|
442
503
|
}
|
|
443
504
|
/**
|
|
444
|
-
* Remove the persistent
|
|
505
|
+
* Remove the persistent delivery worktree.
|
|
445
506
|
*
|
|
446
507
|
* This is an exported utility for the astro platform to call when a project
|
|
447
508
|
* is deleted. The agent-runner does not manage project lifecycles — it only
|
|
448
509
|
* provides the building blocks. The integration point (calling this on
|
|
449
510
|
* project deletion) lives in the astro server, not here.
|
|
450
511
|
*/
|
|
451
|
-
export async function
|
|
452
|
-
if (!existsSync(
|
|
512
|
+
export async function cleanupDeliveryWorktree(gitRoot, deliveryWorktreePath) {
|
|
513
|
+
if (!existsSync(deliveryWorktreePath)) {
|
|
453
514
|
return;
|
|
454
515
|
}
|
|
455
516
|
try {
|
|
456
|
-
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'remove', '--force',
|
|
517
|
+
await execFileAsync('git', ['-C', gitRoot, 'worktree', 'remove', '--force', deliveryWorktreePath], { env: withGitEnv(), timeout: 30_000 });
|
|
457
518
|
}
|
|
458
519
|
catch {
|
|
459
|
-
await rm(
|
|
520
|
+
await rm(deliveryWorktreePath, { recursive: true, force: true });
|
|
460
521
|
}
|
|
461
522
|
await pruneWorktrees(gitRoot);
|
|
462
|
-
console.log(`[worktree] Cleaned up
|
|
523
|
+
console.log(`[worktree] Cleaned up delivery worktree at ${deliveryWorktreePath}`);
|
|
463
524
|
}
|
|
464
525
|
/**
|
|
465
526
|
* Delete remote branch if it exists.
|
|
@@ -709,7 +770,7 @@ async function initSubmodules(worktreePath, stderr) {
|
|
|
709
770
|
stderr?.(`[worktree] Submodule init failed: ${msg}`);
|
|
710
771
|
}
|
|
711
772
|
}
|
|
712
|
-
async function getGitRoot(workingDirectory) {
|
|
773
|
+
export async function getGitRoot(workingDirectory) {
|
|
713
774
|
try {
|
|
714
775
|
const { stdout } = await execFileAsync('git', ['-C', workingDirectory, 'rev-parse', '--show-toplevel'], { env: withGitEnv(), timeout: 5_000 });
|
|
715
776
|
const root = stdout.trim();
|
|
@@ -817,6 +878,29 @@ function validateTaskId(taskId) {
|
|
|
817
878
|
throw new Error(`Invalid taskId format: ${taskId}. Must be alphanumeric with hyphens/underscores/dots, max 200 chars.`);
|
|
818
879
|
}
|
|
819
880
|
}
|
|
881
|
+
/**
|
|
882
|
+
* Validate that a dispatch-provided branch name is safe for git operations.
|
|
883
|
+
* Enforces git check-ref-format rules:
|
|
884
|
+
* - Only alphanumeric, hyphens, underscores, dots, forward slashes
|
|
885
|
+
* - No ".." (path traversal), no "//", no leading/trailing "/"
|
|
886
|
+
* - No ".lock" suffix, no dot-prefixed path components (e.g., ".hidden", "foo/.bar")
|
|
887
|
+
* - Max 200 chars
|
|
888
|
+
*/
|
|
889
|
+
function validateBranchName(name) {
|
|
890
|
+
const safePattern = /^[a-zA-Z0-9/_.-]+$/;
|
|
891
|
+
if (!safePattern.test(name) ||
|
|
892
|
+
name.length > 200 ||
|
|
893
|
+
name.includes('..') ||
|
|
894
|
+
name.includes('//') ||
|
|
895
|
+
name.startsWith('/') ||
|
|
896
|
+
name.endsWith('/') ||
|
|
897
|
+
name.endsWith('.lock') ||
|
|
898
|
+
name === '.' ||
|
|
899
|
+
/(?:^|\/)\./.test(name) // dot-prefixed path components
|
|
900
|
+
) {
|
|
901
|
+
throw new Error(`Invalid branch name from dispatch: ${name.slice(0, 100)}. Must be a valid git ref name, max 200 chars.`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
820
904
|
function sanitize(value) {
|
|
821
905
|
return value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
822
906
|
}
|