mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
fix(agents): allow subagents from multi-repo parent sessions (#2063)
* fix(agents): allow subagents from multi-repo parent sessions Expose Agent cwd in the open build, let cwd select the child repo for worktree isolation, and fall back instead of hard-failing when the session itself is outside a git repository. * fix(agents): persist cwd on resume and forward it to worktree hooks Address final-head review: store explicit Agent cwd in metadata for resume, pass cwd into WorktreeCreate hooks, reject relative cwd in the schema, and make the multi-repo parent regression sandbox portable. * fix(agents): keep child-repo cwd across worktree cleanup and resume Persist explicit Agent cwd even when a worktree is created, preserve it when unchanged worktrees are removed, and base fork worktree notices on the child-repo cwd for multi-repo parent sessions. * fix(agents): re-persist child-repo cwd on every resume Always forward persisted Agent cwd through resume metadata writes so a mid-life resume cannot drop the multi-repo fallback path, and tighten prompt wording to match the missing-git fallback contract. * fix(agents): validate cwd directories and recover from worktree hook failures Require Agent cwd to be an existing directory, always re-persist the original resume metadata cwd, and fall through from failed WorktreeCreate hooks to git when the selected cwd is a git repository. * fix(agents): keep WorktreeCreate hooks authoritative Revert silent git fallback after hook failure. Treat WorktreeCreate hook errors as recoverable in AgentTool so multi-repo cwd overrides still work without bypassing configured hooks at the worktree layer. * fix(agents): only soft-fallback missing-git worktree errors Keep WorktreeCreate hook failures hard-failing so configured hooks stay authoritative in normal git sessions. Soft-fallback remains limited to the missing-git path that #2052 needs. * docs(agents): clarify missing-git cwd fallback wording Align AgentTool prompt and resume debug logs with the missing-git-only soft-fallback contract for multi-repo parent sessions. * fix(agents): keep fork worktree notices on session cwd Inherited fork context paths are relative to the parent session, so the worktree notice must use getCwd() even when isolation used a child-repo cwd. * docs(agents): align runAgent cwd JSDoc with resume persistence * fix(agents): address CodeRabbit cwd validation review notes Use afterAll for schema-test temp cleanup, and preserve the underlying stat failure reason when Agent cwd validation rejects a path. * fix(agents): surface worktree isolation fallback visibly Make the missing-cwd schema test path platform-neutral, and record a user/model-visible notice plus tool-result flag when worktree isolation soft-falls back outside a git repository. * fix(agents): surface worktree fallback when sync agents background Share async_launched payload construction so the sync-to-background path includes worktreeIsolationFallback when worktree isolation soft-falls back.
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { afterAll, describe, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
AgentTool,
|
||||
assertAgentToolCwdAllowed,
|
||||
buildAsyncLaunchedToolData,
|
||||
buildWorktreeIsolationFallbackNotice,
|
||||
formatWorktreeIsolationFallbackResultText,
|
||||
fullInputSchema,
|
||||
inputSchema,
|
||||
isMissingGitAgentWorktreeError,
|
||||
outputSchema,
|
||||
resolveAgentToolCwdOverride,
|
||||
resolveAgentToolEffectiveIsolation,
|
||||
@@ -16,6 +23,15 @@ const baseInput = {
|
||||
prompt: 'Check the implementation',
|
||||
}
|
||||
|
||||
const existingCwd = mkdtempSync(join(tmpdir(), 'openclaude-agent-cwd-'))
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(existingCwd, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
})
|
||||
|
||||
describe('AgentTool input schema model override', () => {
|
||||
test('accepts aliases and custom provider-supported model IDs', () => {
|
||||
const acceptedModels = [
|
||||
@@ -91,21 +107,38 @@ describe('AgentTool input schema isolation contract', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects cwd together with worktree isolation in the full schema', () => {
|
||||
test('accepts cwd together with worktree isolation for multi-repo parents', () => {
|
||||
expect(
|
||||
fullInputSchema().safeParse({
|
||||
...baseInput,
|
||||
isolation: 'worktree',
|
||||
cwd: '/tmp/openclaude-agent',
|
||||
cwd: existingCwd,
|
||||
}).success,
|
||||
).toBe(false)
|
||||
).toBe(true)
|
||||
expect(
|
||||
inputSchema().safeParse({
|
||||
...baseInput,
|
||||
isolation: 'worktree',
|
||||
cwd: existingCwd,
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('accepts cwd without worktree isolation in the full schema', () => {
|
||||
expect(
|
||||
fullInputSchema().safeParse({
|
||||
...baseInput,
|
||||
cwd: '/tmp/openclaude-agent',
|
||||
cwd: existingCwd,
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('exposes cwd on the open-build input schema', () => {
|
||||
expect(inputSchema().shape.cwd).toBeDefined()
|
||||
expect(
|
||||
inputSchema().safeParse({
|
||||
...baseInput,
|
||||
cwd: existingCwd,
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -122,25 +155,96 @@ describe('AgentTool input schema isolation contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects cwd for any effective worktree isolation source', () => {
|
||||
test('allows absolute cwd with or without worktree isolation', () => {
|
||||
expect(() =>
|
||||
assertAgentToolCwdAllowed('/tmp/openclaude-agent', 'worktree'),
|
||||
).toThrow('cwd is mutually exclusive with isolation: "worktree".')
|
||||
assertAgentToolCwdAllowed(existingCwd, 'worktree'),
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertAgentToolCwdAllowed('/tmp/openclaude-agent', undefined),
|
||||
assertAgentToolCwdAllowed(existingCwd, undefined),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('prefers worktree cwd over explicit cwd when both are present defensively', () => {
|
||||
test('rejects relative cwd paths in the schema and helper', () => {
|
||||
expect(
|
||||
resolveAgentToolCwdOverride('/tmp/openclaude-agent', {
|
||||
worktreePath: '/tmp/openclaude-worktree',
|
||||
}),
|
||||
).toBe('/tmp/openclaude-worktree')
|
||||
expect(resolveAgentToolCwdOverride('/tmp/openclaude-agent', null)).toBe(
|
||||
'/tmp/openclaude-agent',
|
||||
inputSchema().safeParse({
|
||||
...baseInput,
|
||||
cwd: 'relative/path',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(() => assertAgentToolCwdAllowed('relative/path', undefined)).toThrow(
|
||||
'cwd must be an absolute path.',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects nonexistent absolute cwd paths', () => {
|
||||
const missingCwd = join(tmpdir(), 'openclaude-missing-cwd-2052')
|
||||
expect(() => assertAgentToolCwdAllowed(missingCwd, undefined)).toThrow(
|
||||
/cwd must be an existing directory \(.+\)\./,
|
||||
)
|
||||
})
|
||||
|
||||
test('detects missing-git worktree errors for fallback', () => {
|
||||
expect(
|
||||
isMissingGitAgentWorktreeError(
|
||||
'Cannot create agent worktree: not in a git repository and no WorktreeCreate hooks are configured.',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isMissingGitAgentWorktreeError(
|
||||
'WorktreeCreate hook failed: no successful output',
|
||||
),
|
||||
).toBe(false)
|
||||
expect(isMissingGitAgentWorktreeError('some other failure')).toBe(false)
|
||||
})
|
||||
|
||||
test('surfaces a clear notice when worktree isolation falls back', () => {
|
||||
const notice = buildWorktreeIsolationFallbackNotice(existingCwd)
|
||||
expect(notice).toContain('running without worktree isolation')
|
||||
expect(notice).toContain(existingCwd)
|
||||
expect(formatWorktreeIsolationFallbackResultText()).toContain(
|
||||
'worktreeIsolationFallback: true',
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAsyncLaunchedToolData carries worktree isolation fallback for backgrounded sync agents', () => {
|
||||
const data = buildAsyncLaunchedToolData({
|
||||
agentId: 'agent-bg-1',
|
||||
description: baseInput.description,
|
||||
prompt: baseInput.prompt,
|
||||
canReadOutputFile: true,
|
||||
worktreeIsolationFallback: true,
|
||||
})
|
||||
|
||||
expect(data.worktreeIsolationFallback).toBe(true)
|
||||
expect(outputSchema().safeParse(data).success).toBe(true)
|
||||
|
||||
const block = AgentTool.mapToolResultToToolResultBlockParam(data, 'toolu_bg')
|
||||
const text = block.content[0]?.type === 'text' ? block.content[0].text : ''
|
||||
expect(text).toContain('worktreeIsolationFallback: true')
|
||||
expect(text).toContain('ran without an isolated worktree')
|
||||
})
|
||||
|
||||
test('buildAsyncLaunchedToolData omits fallback when isolation succeeded', () => {
|
||||
const data = buildAsyncLaunchedToolData({
|
||||
agentId: 'agent-bg-2',
|
||||
description: baseInput.description,
|
||||
prompt: baseInput.prompt,
|
||||
canReadOutputFile: false,
|
||||
worktreeIsolationFallback: false,
|
||||
})
|
||||
|
||||
expect(data.worktreeIsolationFallback).toBeUndefined()
|
||||
})
|
||||
|
||||
test('prefers worktree cwd over explicit cwd when both are present defensively', () => {
|
||||
const worktreePath = join(tmpdir(), 'openclaude-worktree')
|
||||
expect(
|
||||
resolveAgentToolCwdOverride(existingCwd, {
|
||||
worktreePath,
|
||||
}),
|
||||
).toBe(worktreePath)
|
||||
expect(resolveAgentToolCwdOverride(existingCwd, null)).toBe(existingCwd)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentTool output status contract', () => {
|
||||
@@ -161,7 +265,7 @@ describe('AgentTool output status contract', () => {
|
||||
agentId: 'agent-1',
|
||||
description: baseInput.description,
|
||||
prompt: baseInput.prompt,
|
||||
outputFile: '/tmp/openclaude-agent-output.txt',
|
||||
outputFile: join(tmpdir(), 'openclaude-agent-output.txt'),
|
||||
canReadOutputFile: true,
|
||||
},
|
||||
'toolu_1',
|
||||
@@ -170,7 +274,64 @@ describe('AgentTool output status contract', () => {
|
||||
expect(block.type).toBe('tool_result')
|
||||
const text = block.content[0]?.type === 'text' ? block.content[0].text : ''
|
||||
expect(text).toContain('Async agent launched successfully')
|
||||
expect(text).toContain('output_file: /tmp/openclaude-agent-output.txt')
|
||||
expect(text).toContain('output_file:')
|
||||
expect(text).not.toContain('worktreeIsolationFallback: true')
|
||||
})
|
||||
|
||||
test('surfaces worktree isolation fallback on async-launched tool results', () => {
|
||||
const block = AgentTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
status: 'async_launched',
|
||||
agentId: 'agent-1',
|
||||
description: baseInput.description,
|
||||
prompt: baseInput.prompt,
|
||||
outputFile: join(tmpdir(), 'openclaude-agent-output.txt'),
|
||||
canReadOutputFile: false,
|
||||
worktreeIsolationFallback: true,
|
||||
},
|
||||
'toolu_1',
|
||||
)
|
||||
|
||||
const text = block.content[0]?.type === 'text' ? block.content[0].text : ''
|
||||
expect(text).toContain('worktreeIsolationFallback: true')
|
||||
expect(text).toContain('ran without an isolated worktree')
|
||||
})
|
||||
|
||||
test('surfaces worktree isolation fallback on completed tool results', () => {
|
||||
const block = AgentTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
status: 'completed',
|
||||
prompt: baseInput.prompt,
|
||||
agentId: 'agent-1',
|
||||
agentType: 'general-purpose',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
totalToolUseCount: 1,
|
||||
totalDurationMs: 10,
|
||||
totalTokens: 5,
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
service_tier: null,
|
||||
cache_creation: null,
|
||||
},
|
||||
worktreeIsolationFallback: true,
|
||||
},
|
||||
'toolu_1',
|
||||
)
|
||||
|
||||
const texts = Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter(
|
||||
(c): c is { type: 'text'; text: string } => c.type === 'text',
|
||||
)
|
||||
.map(c => c.text)
|
||||
.join('\n')
|
||||
: ''
|
||||
expect(texts).toContain('worktreeIsolationFallback: true')
|
||||
expect(texts).toContain('ran without an isolated worktree')
|
||||
})
|
||||
|
||||
test('throws for unsupported output statuses', () => {
|
||||
@@ -190,7 +351,7 @@ describe('AgentTool output status contract', () => {
|
||||
agentId: 'agent-1',
|
||||
description: baseInput.description,
|
||||
prompt: baseInput.prompt,
|
||||
outputFile: '/tmp/openclaude-agent-output.txt',
|
||||
outputFile: join(tmpdir(), 'openclaude-agent-output.txt'),
|
||||
canReadOutputFile: true,
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import { statSync } from 'fs';
|
||||
import { isAbsolute } from 'path';
|
||||
import * as React from 'react';
|
||||
import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js';
|
||||
import type { Message as MessageType, NormalizedUserMessage } from 'src/types/message.js';
|
||||
@@ -104,11 +106,11 @@ export const fullInputSchema = lazySchema(() => {
|
||||
mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).')
|
||||
});
|
||||
return baseInputSchema().merge(multiAgentInputSchema).extend({
|
||||
isolation: z.enum(['worktree']).optional().describe('Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'),
|
||||
cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".')
|
||||
}).refine(input => !(input.isolation === 'worktree' && input.cwd !== undefined), {
|
||||
isolation: z.enum(['worktree']).optional().describe('Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. When the session is outside a git repository (for example a parent of multiple repos), pass cwd set to the target repository root so the worktree is created from that repo.'),
|
||||
cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. When isolation is "worktree", cwd selects which git repository to create the worktree from — use this when the session cwd is not itself a git repo (for example a folder parenting multiple repos).')
|
||||
}).refine(input => input.cwd === undefined || isAbsolute(input.cwd), {
|
||||
path: ['cwd'],
|
||||
message: 'cwd is mutually exclusive with isolation: "worktree".'
|
||||
message: 'cwd must be an absolute path.',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,9 +121,9 @@ export const fullInputSchema = lazySchema(() => {
|
||||
// type, but call() destructures via the explicit AgentToolInput type below
|
||||
// which always includes all optional fields.
|
||||
export const inputSchema = lazySchema(() => {
|
||||
const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({
|
||||
cwd: true
|
||||
});
|
||||
// cwd stays available in the open build so multi-repo parent sessions can
|
||||
// pin subagents (and worktree isolation) to a child repository (#2052).
|
||||
const schema = fullInputSchema();
|
||||
|
||||
// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
|
||||
// was removed in 906da6c723): the divergence window is one-session-per-
|
||||
@@ -137,7 +139,7 @@ export const inputSchema = lazySchema(() => {
|
||||
type InputSchema = ReturnType<typeof inputSchema>;
|
||||
|
||||
// Explicit type widens the schema inference to always include all optional
|
||||
// fields even when .omit() strips them for gating (cwd, run_in_background).
|
||||
// fields even when .omit() strips them for gating (run_in_background).
|
||||
// subagent_type is optional; call() defaults it to general-purpose when the
|
||||
// fork gate is off, or routes to the fork path when the gate is on.
|
||||
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
|
||||
@@ -161,12 +163,31 @@ export function resolveAgentToolEffectiveIsolation(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* cwd may be combined with worktree isolation: it names the repository used
|
||||
* as the worktree base when the session itself is outside a git repo.
|
||||
* Relative paths are rejected so tool callers cannot depend on ambient cwd.
|
||||
* The path must exist and be a directory so spawn fails closed on typos.
|
||||
*/
|
||||
export function assertAgentToolCwdAllowed(
|
||||
cwd: string | undefined,
|
||||
effectiveIsolation: AgentToolIsolation,
|
||||
_effectiveIsolation?: AgentToolIsolation,
|
||||
): void {
|
||||
if (cwd !== undefined && effectiveIsolation === 'worktree') {
|
||||
throw new Error('cwd is mutually exclusive with isolation: "worktree".');
|
||||
if (cwd === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!isAbsolute(cwd)) {
|
||||
throw new Error('cwd must be an absolute path.');
|
||||
}
|
||||
let stats;
|
||||
try {
|
||||
stats = statSync(cwd);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`cwd must be an existing directory (${reason}).`);
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error('cwd must be an existing directory.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,11 +198,50 @@ export function resolveAgentToolCwdOverride(
|
||||
return worktreeInfo?.worktreePath ?? cwd;
|
||||
}
|
||||
|
||||
/** True when worktree creation failed only because no git root was available. */
|
||||
export function isMissingGitAgentWorktreeError(message: string): boolean {
|
||||
return message.includes('Cannot create agent worktree: not in a git repository');
|
||||
}
|
||||
|
||||
/**
|
||||
* User/model-visible notice when worktree isolation was requested but could
|
||||
* not be created (missing git). Edits are not isolated in a worktree copy.
|
||||
*/
|
||||
export function buildWorktreeIsolationFallbackNotice(cwdPath: string): string {
|
||||
return `Worktree isolation was requested but could not be created because no git repository is available. This agent is running without worktree isolation — edits modify files directly in ${cwdPath}, not an isolated worktree copy.`;
|
||||
}
|
||||
|
||||
/** Trailer line(s) for tool results when worktree isolation fell back. */
|
||||
export function formatWorktreeIsolationFallbackResultText(): string {
|
||||
return 'worktreeIsolationFallback: true\nnote: Worktree isolation was unavailable; this agent ran without an isolated worktree (edits are not sandboxed in a worktree).';
|
||||
}
|
||||
|
||||
/** Shared async_launched payload for direct-async and sync-to-background paths. */
|
||||
export function buildAsyncLaunchedToolData(args: {
|
||||
agentId: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
canReadOutputFile: boolean;
|
||||
worktreeIsolationFallback?: boolean;
|
||||
}) {
|
||||
return {
|
||||
isAsync: true as const,
|
||||
status: 'async_launched' as const,
|
||||
agentId: args.agentId,
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
outputFile: getTaskOutputPath(args.agentId),
|
||||
canReadOutputFile: args.canReadOutputFile,
|
||||
...(args.worktreeIsolationFallback && { worktreeIsolationFallback: true as const }),
|
||||
};
|
||||
}
|
||||
|
||||
// Output schema - multi-agent spawned schema added dynamically at runtime when enabled
|
||||
export const outputSchema = lazySchema(() => {
|
||||
const syncOutputSchema = agentToolResultSchema().extend({
|
||||
status: z.literal('completed'),
|
||||
prompt: z.string()
|
||||
prompt: z.string(),
|
||||
worktreeIsolationFallback: z.boolean().optional().describe('True when worktree isolation was requested but fell back because no git repository was available'),
|
||||
});
|
||||
const asyncOutputSchema = z.object({
|
||||
status: z.literal('async_launched'),
|
||||
@@ -189,7 +249,8 @@ export const outputSchema = lazySchema(() => {
|
||||
description: z.string().describe('The description of the task'),
|
||||
prompt: z.string().describe('The prompt for the agent'),
|
||||
outputFile: z.string().describe('Path to the output file for checking agent progress'),
|
||||
canReadOutputFile: z.boolean().optional().describe('Whether the calling agent has Read/Bash tools to check progress')
|
||||
canReadOutputFile: z.boolean().optional().describe('Whether the calling agent has Read/Bash tools to check progress'),
|
||||
worktreeIsolationFallback: z.boolean().optional().describe('True when worktree isolation was requested but fell back because no git repository was available'),
|
||||
});
|
||||
return z.union([syncOutputSchema, asyncOutputSchema]);
|
||||
});
|
||||
@@ -545,8 +606,8 @@ export const AgentTool = buildTool({
|
||||
is_fork: isForkPath
|
||||
});
|
||||
|
||||
// Agent frontmatter can force worktree isolation too, so validate cwd
|
||||
// against the effective mode instead of only the raw tool input.
|
||||
// Agent frontmatter can force worktree isolation too; cwd may still be
|
||||
// supplied as the repository root for that worktree (#2052).
|
||||
const effectiveIsolation = resolveAgentToolEffectiveIsolation(
|
||||
isolation,
|
||||
selectedAgent.isolation,
|
||||
@@ -669,17 +730,26 @@ export const AgentTool = buildTool({
|
||||
gitRoot?: string;
|
||||
hookBased?: boolean;
|
||||
} | null = null;
|
||||
let worktreeIsolationFallback = false;
|
||||
if (effectiveIsolation === 'worktree') {
|
||||
const slug = `agent-${earlyAgentId.slice(0, 8)}`;
|
||||
try {
|
||||
worktreeInfo = await createAgentWorktree(slug);
|
||||
// When the session is outside a git repo (e.g. a parent of multiple
|
||||
// repos), cwd names the child repository used as the worktree base.
|
||||
worktreeInfo = await createAgentWorktree(slug, cwd ? { cwd } : undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('Cannot create agent worktree: not in a git repository')) {
|
||||
if (isolation === 'worktree') {
|
||||
throw error;
|
||||
}
|
||||
logForDebugging('Agent worktree isolation unavailable outside a git repository; falling back to the current working directory.');
|
||||
if (isMissingGitAgentWorktreeError(message)) {
|
||||
// Fall back for both explicit and agent-definition isolation so
|
||||
// multi-repo parent sessions can still spawn subagents (#2052).
|
||||
// Prefer the caller-supplied cwd when present so the agent still
|
||||
// lands inside the target child repository without a worktree.
|
||||
worktreeIsolationFallback = true;
|
||||
logForDebugging(
|
||||
cwd
|
||||
? `Agent worktree isolation unavailable outside a git repository; falling back to cwd override ${cwd}.`
|
||||
: 'Agent worktree isolation unavailable outside a git repository; falling back to the current working directory.',
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
@@ -691,9 +761,18 @@ export const AgentTool = buildTool({
|
||||
// so it appears as the most recent guidance the child sees.
|
||||
if (isForkPath && worktreeInfo) {
|
||||
promptMessages.push(createUserMessage({
|
||||
// parentCwd must remain the session cwd: inherited context paths are
|
||||
// relative to the parent agent, even when worktree creation used a
|
||||
// child-repo cwd override for multi-repo parents.
|
||||
content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath)
|
||||
}));
|
||||
}
|
||||
// Missing-git soft-fallback: tell the child edits are not worktree-isolated.
|
||||
if (worktreeIsolationFallback) {
|
||||
promptMessages.push(createUserMessage({
|
||||
content: buildWorktreeIsolationFallbackNotice(cwd ?? getCwd())
|
||||
}));
|
||||
}
|
||||
const runAgentParams: Parameters<typeof runAgent>[0] = {
|
||||
agentDefinition: selectedAgent,
|
||||
promptMessages,
|
||||
@@ -726,12 +805,15 @@ export const AgentTool = buildTool({
|
||||
useExactTools: true
|
||||
}),
|
||||
worktreePath: worktreeInfo?.worktreePath,
|
||||
// Always persist an explicit child-repo cwd so resume can fall back to
|
||||
// it if the worktree is later removed or cleaned up (#2052).
|
||||
cwd,
|
||||
description,
|
||||
agentName: name,
|
||||
};
|
||||
|
||||
// Helper to wrap execution with a cwd override. Worktree wins if present;
|
||||
// cwd is rejected for worktree isolation above, but keep this defensive.
|
||||
// otherwise an explicit cwd pins the agent to a child repo / directory.
|
||||
const cwdOverridePath = resolveAgentToolCwdOverride(cwd, worktreeInfo);
|
||||
const wrapWithCwd = <T,>(fn: () => T): T => cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn();
|
||||
|
||||
@@ -763,11 +845,12 @@ export const AgentTool = buildTool({
|
||||
if (!changed) {
|
||||
await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot);
|
||||
// Clear worktreePath from metadata so resume doesn't try to use
|
||||
// a deleted directory. Fire-and-forget to match runAgent's
|
||||
// writeAgentMetadata handling.
|
||||
// a deleted directory, but keep an explicit child-repo cwd when
|
||||
// present so resume can still land in the target repository.
|
||||
void writeAgentMetadata(asAgentId(earlyAgentId), {
|
||||
agentType: selectedAgent.agentType,
|
||||
description
|
||||
...(cwd && { cwd }),
|
||||
...(description && { description }),
|
||||
}).catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`));
|
||||
return {};
|
||||
}
|
||||
@@ -847,15 +930,13 @@ export const AgentTool = buildTool({
|
||||
})));
|
||||
const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME));
|
||||
return {
|
||||
data: {
|
||||
isAsync: true as const,
|
||||
status: 'async_launched' as const,
|
||||
data: buildAsyncLaunchedToolData({
|
||||
agentId: agentBackgroundTask.agentId,
|
||||
description: description,
|
||||
prompt: prompt,
|
||||
outputFile: getTaskOutputPath(agentBackgroundTask.agentId),
|
||||
canReadOutputFile
|
||||
}
|
||||
description,
|
||||
prompt,
|
||||
canReadOutputFile,
|
||||
worktreeIsolationFallback,
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Create an explicit agentId for sync agents
|
||||
@@ -1138,15 +1219,13 @@ export const AgentTool = buildTool({
|
||||
// Return async_launched result immediately
|
||||
const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME));
|
||||
return {
|
||||
data: {
|
||||
isAsync: true as const,
|
||||
status: 'async_launched' as const,
|
||||
data: buildAsyncLaunchedToolData({
|
||||
agentId: backgroundedTaskId,
|
||||
description: description,
|
||||
prompt: prompt,
|
||||
outputFile: getTaskOutputPath(backgroundedTaskId),
|
||||
canReadOutputFile
|
||||
}
|
||||
description,
|
||||
prompt,
|
||||
canReadOutputFile,
|
||||
worktreeIsolationFallback,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1360,7 +1439,8 @@ export const AgentTool = buildTool({
|
||||
status: 'completed' as const,
|
||||
prompt,
|
||||
...agentResult,
|
||||
...worktreeResult
|
||||
...worktreeResult,
|
||||
...(worktreeIsolationFallback && { worktreeIsolationFallback: true as const }),
|
||||
}
|
||||
};
|
||||
}));
|
||||
@@ -1420,7 +1500,10 @@ The agent is now running and will receive instructions via mailbox.`
|
||||
if (data.status === 'async_launched') {
|
||||
const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`;
|
||||
const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Briefly tell the user what you launched and end your response — agent results will arrive in a subsequent message. You may continue first ONLY if you have other tasks on clearly different files that this agent is not touching.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`;
|
||||
const text = `${prefix}\n${instructions}`;
|
||||
const isolationFallbackText = data.worktreeIsolationFallback
|
||||
? `\n${formatWorktreeIsolationFallbackResultText()}`
|
||||
: '';
|
||||
const text = `${prefix}\n${instructions}${isolationFallbackText}`;
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
@@ -1433,6 +1516,9 @@ The agent is now running and will receive instructions via mailbox.`
|
||||
if (data.status === 'completed') {
|
||||
const worktreeData = data as Record<string, unknown>;
|
||||
const worktreeInfoText = worktreeData.worktreePath ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` : '';
|
||||
const isolationFallbackText = worktreeData.worktreeIsolationFallback
|
||||
? `\n${formatWorktreeIsolationFallbackResultText()}`
|
||||
: '';
|
||||
// If the subagent completes with no content, the tool_result is just the
|
||||
// agentId/usage trailer below — a metadata-only block at the prompt tail.
|
||||
// Some models read that as "nothing to act on" and end their turn
|
||||
@@ -1446,7 +1532,9 @@ The agent is now running and will receive instructions via mailbox.`
|
||||
// 34M Explore runs/week ≈ 1-2 Gtok/week). Telemetry doesn't parse this
|
||||
// block (it uses logEvent in finalizeAgentTool), so dropping is safe.
|
||||
// agentType is optional for resume compat — missing means show trailer.
|
||||
if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText) {
|
||||
// Keep the trailer when isolation fell back so the parent sees that
|
||||
// edits were not worktree-isolated.
|
||||
if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText && !isolationFallbackText) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
@@ -1458,7 +1546,7 @@ The agent is now running and will receive instructions via mailbox.`
|
||||
type: 'tool_result',
|
||||
content: [...contentOrMarker, {
|
||||
type: 'text',
|
||||
text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText}
|
||||
text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText}${isolationFallbackText}
|
||||
<usage>total_tokens: ${data.totalTokens}
|
||||
tool_uses: ${data.totalToolUseCount}
|
||||
duration_ms: ${data.totalDurationMs}</usage>`
|
||||
|
||||
@@ -52,5 +52,10 @@ describe('AgentTool prompt isolation contract', () => {
|
||||
|
||||
expect(prompt).toContain('isolation: "worktree"')
|
||||
expect(prompt).not.toContain('isolation: "remote"')
|
||||
expect(prompt).toContain('parent folder that contains multiple git repos')
|
||||
expect(prompt).toContain('set `cwd` to the absolute path of the target child repository')
|
||||
expect(prompt).toContain('the agent still runs with that `cwd` override instead of failing')
|
||||
expect(prompt).toContain('tool result notes that worktree isolation was unavailable')
|
||||
expect(prompt).toContain('If worktree creation fails only because no git repository is available')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -261,7 +261,8 @@ Usage notes:
|
||||
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
|
||||
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${AGENT_TOOL_NAME} tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
|
||||
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.${
|
||||
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.
|
||||
- When the current session is outside a git repository (for example a parent folder that contains multiple git repos), set \`cwd\` to the absolute path of the target child repository. You can combine \`cwd\` with \`isolation: "worktree"\` so the worktree is created from that child repo. If worktree creation fails only because no git repository is available, the agent still runs with that \`cwd\` override instead of failing, and the tool result notes that worktree isolation was unavailable.${
|
||||
isInProcessTeammate()
|
||||
? `
|
||||
- The run_in_background, name, team_name, and mode parameters are not available in this context. Only synchronous subagents are supported.`
|
||||
|
||||
@@ -78,13 +78,14 @@ export async function resumeAgentBackground({
|
||||
transcript.contentReplacements,
|
||||
)
|
||||
// Best-effort: if the original worktree was removed externally, fall back
|
||||
// to parent cwd rather than crashing on chdir later.
|
||||
// to a persisted cwd override (multi-repo parent sessions) or parent cwd
|
||||
// rather than crashing on chdir later.
|
||||
const resumedWorktreePath = meta?.worktreePath
|
||||
? await fsp.stat(meta.worktreePath).then(
|
||||
s => (s.isDirectory() ? meta.worktreePath : undefined),
|
||||
() => {
|
||||
logForDebugging(
|
||||
`Resumed worktree ${meta.worktreePath} no longer exists; falling back to parent cwd`,
|
||||
`Resumed worktree ${meta.worktreePath} no longer exists; falling back to persisted cwd or parent cwd`,
|
||||
)
|
||||
return undefined
|
||||
},
|
||||
@@ -95,6 +96,20 @@ export async function resumeAgentBackground({
|
||||
const now = new Date()
|
||||
await fsp.utimes(resumedWorktreePath, now, now)
|
||||
}
|
||||
const resumedCwdOverride = meta?.cwd
|
||||
? await fsp.stat(meta.cwd).then(
|
||||
s => (s.isDirectory() ? meta.cwd : undefined),
|
||||
() => {
|
||||
logForDebugging(
|
||||
`Resumed cwd override ${meta.cwd} no longer exists; falling back to parent cwd`,
|
||||
)
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
: undefined
|
||||
// Prefer the live worktree when present; otherwise land in the persisted
|
||||
// child-repo cwd (multi-repo parents) before falling back to the session cwd.
|
||||
const resumedCwdPath = resumedWorktreePath ?? resumedCwdOverride
|
||||
|
||||
// Skip filterDeniedAgents re-gating — original spawn already passed permission checks
|
||||
let selectedAgent: AgentDefinition
|
||||
@@ -179,7 +194,7 @@ export async function resumeAgentBackground({
|
||||
model: undefined,
|
||||
// Fork resume: pass parent's system prompt (cache-identical prefix).
|
||||
// Non-fork: undefined → runAgent recomputes under wrapWithCwd so
|
||||
// getCwd() sees resumedWorktreePath.
|
||||
// getCwd() sees resumedWorktreePath / resumed cwd override.
|
||||
override: isResumedFork
|
||||
? { systemPrompt: forkParentSystemPrompt }
|
||||
: undefined,
|
||||
@@ -188,8 +203,11 @@ export async function resumeAgentBackground({
|
||||
// original fork. Re-supplying it would cause duplicate tool_use IDs.
|
||||
forkContextMessages: undefined,
|
||||
...(isResumedFork && { useExactTools: true }),
|
||||
// Re-persist so metadata survives runAgent's writeAgentMetadata overwrite
|
||||
// Re-persist so metadata survives runAgent's writeAgentMetadata overwrite.
|
||||
// Always keep the original meta.cwd string even if a transient stat check
|
||||
// failed for execution — a later resume may still be able to use it.
|
||||
worktreePath: resumedWorktreePath,
|
||||
cwd: meta?.cwd,
|
||||
description: meta?.description,
|
||||
contentReplacementState: resumedReplacementState,
|
||||
}
|
||||
@@ -225,7 +243,7 @@ export async function resumeAgentBackground({
|
||||
}
|
||||
|
||||
const wrapWithCwd = <T>(fn: () => T): T =>
|
||||
resumedWorktreePath ? runWithCwdOverride(resumedWorktreePath, fn) : fn()
|
||||
resumedCwdPath ? runWithCwdOverride(resumedCwdPath, fn) : fn()
|
||||
|
||||
void runWithAgentContext(asyncAgentContext, () =>
|
||||
wrapWithCwd(() =>
|
||||
|
||||
@@ -263,6 +263,7 @@ export async function* runAgent({
|
||||
contentReplacementState,
|
||||
useExactTools,
|
||||
worktreePath,
|
||||
cwd,
|
||||
description,
|
||||
transcriptSubdir,
|
||||
onQueryProgress,
|
||||
@@ -317,6 +318,10 @@ export async function* runAgent({
|
||||
/** Worktree path if the agent was spawned with isolation: "worktree".
|
||||
* Persisted to metadata so resume can restore the correct cwd. */
|
||||
worktreePath?: string
|
||||
/** Explicit cwd override for the agent's working directory. Persisted for
|
||||
* resume even when a worktree exists, so multi-repo parent sessions can
|
||||
* fall back to the child-repo path after worktree cleanup. */
|
||||
cwd?: string
|
||||
/** Original task description from AgentTool input. Persisted to metadata
|
||||
* so a resumed agent's notification can show the original description. */
|
||||
description?: string
|
||||
@@ -782,6 +787,9 @@ export async function* runAgent({
|
||||
void writeAgentMetadata(agentId, {
|
||||
agentType: agentDefinition.agentType,
|
||||
...(worktreePath && { worktreePath }),
|
||||
// Keep explicit cwd even when a worktree exists so resume can fall back
|
||||
// to the child repo if the worktree is later removed.
|
||||
...(cwd && { cwd }),
|
||||
...(description && { description }),
|
||||
}).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`))
|
||||
|
||||
|
||||
@@ -5184,9 +5184,11 @@ export function hasWorktreeCreateHook(): boolean {
|
||||
*/
|
||||
export async function executeWorktreeCreateHook(
|
||||
name: string,
|
||||
options?: { cwd?: string },
|
||||
): Promise<{ worktreePath: string }> {
|
||||
const hookInput = {
|
||||
...createBaseHookInput(undefined),
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
hook_event_name: 'WorktreeCreate' as const,
|
||||
name,
|
||||
}
|
||||
|
||||
@@ -274,6 +274,10 @@ export type AgentMetadata = {
|
||||
agentType: string
|
||||
/** Worktree path if the agent was spawned with isolation: "worktree" */
|
||||
worktreePath?: string
|
||||
/** Explicit AgentTool cwd override for the agent's working directory.
|
||||
* Persisted even when a worktree exists so resume can fall back to the
|
||||
* child-repo directory after worktree cleanup (multi-repo parents). */
|
||||
cwd?: string
|
||||
/** Original task description from the AgentTool input. Persisted so a
|
||||
* resumed agent's notification can show the original description instead
|
||||
* of a placeholder. Optional — older metadata files lack this field. */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Child-process fixture for worktree.multiRepoParent.test.ts.
|
||||
//
|
||||
// Same rationale as worktree.agentBase.fixture.ts: run createAgentWorktree in
|
||||
// a clean process so leaked mock.module stubs from other suites cannot make
|
||||
// git look unavailable.
|
||||
import {
|
||||
getClaudeConfigHomeDir,
|
||||
setClaudeConfigHomeDirForTesting,
|
||||
} from './envUtils.js'
|
||||
import { createAgentWorktree } from './worktree.js'
|
||||
|
||||
const [cfgDir, targetCwd, name] = process.argv.slice(2)
|
||||
|
||||
if (!cfgDir || !targetCwd || !name) {
|
||||
process.stderr.write('usage: <cfgDir> <targetCwd> <name>\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
setClaudeConfigHomeDirForTesting(cfgDir)
|
||||
getClaudeConfigHomeDir.cache?.clear?.()
|
||||
|
||||
try {
|
||||
const result = await createAgentWorktree(name, { cwd: targetCwd })
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
worktreePath: result.worktreePath,
|
||||
gitRoot: result.gitRoot ?? null,
|
||||
}),
|
||||
)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { execFileSync } from 'child_process'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { findGitRoot } from './git.js'
|
||||
|
||||
// Regression for #2052 — sessions started in a non-git parent of multiple
|
||||
// child repos must still be able to create agent worktrees when cwd points
|
||||
// at a child repository.
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
},
|
||||
}).trim()
|
||||
}
|
||||
|
||||
const FIXTURE = join(import.meta.dir, 'worktree.multiRepoParent.fixture.ts')
|
||||
|
||||
/**
|
||||
* Pick a sandbox root with no ancestor .git so the multi-repo parent truly
|
||||
* has a null findGitRoot. Prefer os.tmpdir(), then /var/tmp, then a private
|
||||
* dir under the home directory.
|
||||
*/
|
||||
function resolveSandboxRoot(): string {
|
||||
const candidates = [tmpdir(), '/var/tmp', join(process.env.HOME ?? '', '.cache')]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || !existsSync(candidate)) continue
|
||||
try {
|
||||
const probe = mkdtempSync(join(candidate, 'openclaude-2052-probe-'))
|
||||
const parentHasGit = findGitRoot(probe) !== null
|
||||
rmSync(probe, { recursive: true, force: true })
|
||||
if (!parentHasGit) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'No sandbox root without an ancestor .git was available for the multi-repo parent regression test',
|
||||
)
|
||||
}
|
||||
|
||||
function runCreateAgentWorktree(
|
||||
cfgDir: string,
|
||||
targetCwd: string,
|
||||
name: string,
|
||||
):
|
||||
| { ok: true; worktreePath: string; gitRoot: string | null }
|
||||
| { ok: false; error: string } {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
['run', FIXTURE, cfgDir, targetCwd, name],
|
||||
{ encoding: 'utf8', timeout: 30_000 },
|
||||
)
|
||||
return JSON.parse(stdout) as
|
||||
| { ok: true; worktreePath: string; gitRoot: string | null }
|
||||
| { ok: false; error: string }
|
||||
}
|
||||
|
||||
test(
|
||||
'createAgentWorktree fails for a non-git multi-repo parent, succeeds for a child repo cwd',
|
||||
() => {
|
||||
const sandboxRoot = resolveSandboxRoot()
|
||||
const root = mkdtempSync(join(sandboxRoot, 'openclaude-2052-'))
|
||||
const cfgDir = mkdtempSync(join(sandboxRoot, 'openclaude-2052-cfg-'))
|
||||
const parent = join(root, 'parent')
|
||||
const repoA = join(parent, 'repo-a')
|
||||
const repoB = join(parent, 'repo-b')
|
||||
|
||||
try {
|
||||
mkdirSync(repoA, { recursive: true })
|
||||
mkdirSync(repoB, { recursive: true })
|
||||
|
||||
for (const repo of [repoA, repoB]) {
|
||||
git(repo, 'init', '-b', 'main')
|
||||
writeFileSync(join(repo, 'README.md'), `${repo}\n`)
|
||||
git(repo, 'add', '.')
|
||||
git(repo, 'commit', '-m', 'init')
|
||||
}
|
||||
|
||||
expect(findGitRoot(parent)).toBeNull()
|
||||
|
||||
const parentResult = runCreateAgentWorktree(
|
||||
cfgDir,
|
||||
parent,
|
||||
'issue-2052-parent',
|
||||
)
|
||||
expect(parentResult.ok).toBe(false)
|
||||
if (!parentResult.ok) {
|
||||
expect(parentResult.error).toContain(
|
||||
'Cannot create agent worktree: not in a git repository',
|
||||
)
|
||||
}
|
||||
|
||||
const childResult = runCreateAgentWorktree(
|
||||
cfgDir,
|
||||
repoA,
|
||||
'issue-2052-child',
|
||||
)
|
||||
expect(childResult.ok).toBe(true)
|
||||
if (childResult.ok) {
|
||||
expect(existsSync(childResult.worktreePath)).toBe(true)
|
||||
expect(childResult.gitRoot).toBe(repoA)
|
||||
expect(existsSync(join(childResult.worktreePath, 'README.md'))).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
git(repoA, 'worktree', 'remove', '--force', childResult.worktreePath)
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(cfgDir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
@@ -1062,14 +1062,16 @@ export async function createAgentWorktree(
|
||||
}> {
|
||||
validateWorktreeSlug(slug)
|
||||
|
||||
// Resolve the parent session's working directory once. Defaults to the
|
||||
// ambient session cwd; callers (and tests) may pin it explicitly so both the
|
||||
// canonical-root and parent-HEAD lookups below stay consistent.
|
||||
const sessionCwd = options?.cwd ?? getCwd()
|
||||
|
||||
// Try hook-based worktree creation first (allows user-configured VCS)
|
||||
// Try hook-based worktree creation first (allows user-configured VCS).
|
||||
// Forward sessionCwd so hooks operating from a multi-repo parent can target
|
||||
// the selected child repository (#2052). Hook failure remains terminal here —
|
||||
// AgentTool decides whether to fall back to a cwd override.
|
||||
if (hasWorktreeCreateHook()) {
|
||||
const hookResult = await executeWorktreeCreateHook(slug)
|
||||
const hookResult = await executeWorktreeCreateHook(slug, {
|
||||
cwd: sessionCwd,
|
||||
})
|
||||
logForDebugging(
|
||||
`Created hook-based agent worktree at: ${hookResult.worktreePath}`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user