Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
Status: implemented
Date: 2026-10-24
---
# 隔离 worktree 子代理一律按自己的 ALS 工作目录取值,并让「它在哪个目录、碰了哪个目录」进轨迹

## 决定了什么

根因是同一个:`withAgentCwd` 只改 ALS、不 chdir,子代理里所有直接读 `process.cwd()` 的地方拿到的都是主仓根。按 §3 四层模型逐层收口:

**② 进程层(逻辑)**
- `sub-agent.ts`:四处 `enhanceSubAgentPrompt(..., process.cwd())`、spawn 的 `workdir`/`cwd`、teammate 注册 cwd 全改 `getCwd()`。系统提示词的「当前工作目录」从此是 worktree,D13 隔离提示也真正出现了,并且点名 worktree 根和主仓根。
- 子代理 JIT:项目根取 `getAgentRoot() ?? process.cwd()`,并在调用时同步取值,不依赖队列回调里 ALS 的传播。`jit-telemetry` 的 `rel()` 先按 projectRoot `resolve` 再求相对路径,否则相对入参会被按 process.cwd() 补全,判成项目外。
- `bash.resolveCwd`:在子代理上下文里,cwd 失效时回退到子代理自己的 root;root 也不存在就报错,命令不执行。不再回退到 `getOriginalCwd()`,也就是主仓。
- `checker`:sed 目标解析和 LLM 分类器共用 `resolveBashCwdForCheck`,口径是 input.cwd 优先、再取 getCwd()。分类器原先拿的是 `process.cwd()`。
- 隔离 worktree 的路径判定收成一处:`cwd-context.splitIsolatedWorktreePath` / `getIsolatedWorktree`。W12、D13、digest 都用它。
- 自定义子代理(`executeCustom`)新增 `cwd` 字段。有它时包 `withAgentCwd`,并强制走进程内执行。

**③ 运行时层**
- `advisories.checkDependencyConsistency` 改为接收实际建成的 symlink 目录清单。对每个 `**/node_modules`,从所在包目录逐级向上比对 lockfile。原先只认顶层的 `"node_modules"`,monorepo 子包配置下整条告警不生效。

**可观测 / 数据层**
- `SubagentStart` 新增 `isolation`、`agent_cwd`。`agent_cwd` 列进 `LOCAL_ONLY_EVENT_DATA_FIELDS`,上传前剥离。
- `jit_context` 新增 `isolated_worktree`。
- `sidechain_start` 落盘 `systemPrompt`、`cwd`。sidechain 只在本地,不进上传通道。之前子代理的系统提示词在任何轨迹里都看不到。
- digest 子代理段新增 `isolation: {spans, absPathCalls, mainRepoPathCalls}`。这就是修复效果的主口径:隔离子代理带绝对路径的工具调用里,有多少落在主仓、却不在自己的 worktree 内。

## 放弃了什么(以及为什么不选)

- **在 `withAgentCwd` 里顺手 `process.chdir`**:chdir 是进程级全局态,并发子代理会互相串台。ALS 方案存在的理由就是为了不 chdir(`swarm/team.ts` 以前只能串行就是这个原因)。
- **只修提示词,不动 JIT 和 bash**:提示词修好后,模型大概率改用相对路径,但 JIT 加载错规则文件、bash 在 cwd 被删后落进主仓,这两件事和模型怎么写路径无关,单修提示词不会消失。
- **bash 失效时回退到 worktree 根之后还是找不到,就回退到主仓**:这正是要消除的静默越界。宁可报错。
- **子代理系统提示词写进 `events.jsonl`**:events 会上传,提示词里有绝对路径,还可能有预加载的技能和记忆。sidechain 只在本地,是更合适的落点。
- **digest 口径把相对路径也计入分母**:相对路径按子代理 cwd 解析,不可能越界。计进去只会稀释信号,修复前后的比值都会被压低。

## 拿什么证明它生效了

- 修复前基线(本机轨迹,2026-10-16~22,4 个会话、11 个 `sub_agent(isolation:"worktree")`):529 次带路径工具调用里 501 次打在主仓绝对路径(约 95%),9/11 第 1 轮就用了主仓路径,D13 提示零次出现。取数方法:`raw.jsonl` 的派发入参 ↔ `sessions/<proj>/` 的 sidechain,用 `parentToolUseId` 关联。
- 新测试 `packages/core/tests/agent/isolated-subagent-cwd.test.ts`(13 条)以及 digest、advisories 的新增用例。每条修复都做了变异自证,把修复改回旧行为后对应用例变红:提示词 cwd、JIT 根、bash 兜底(2 条)、SubagentStart 字段、上传剥离、自定义路径 cwd、sidechain systemPrompt、end 无 start 守卫、digest 越界判据、monorepo lockfile。
- 修复后的端到端验证要在真实会话里看:`bun scripts/trace-digest.ts <id>` 的「隔离子代理 N 个:绝对路径调用越界到主仓 x/y」应当接近 0。样本少,需要积累几轮会话。旧会话没有 `agent_cwd`,不出这个口径,所以修复前后不可直接比曲线,只能对比上面的手工基线。
101 changes: 74 additions & 27 deletions packages/core/src/agent/sub-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,10 @@ import { getTeamMemberContext } from "../swarm/team-context.ts";
import { registerSession, unregisterSession } from "../session/concurrent.ts";
import { getAgentSystemPrompt, resolveAgent, BUILTIN_AGENTS } from "./agent-definition.ts";
import { platform, homedir } from "os";
import { cwd } from "process";
import { dirname, join, sep } from "path";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { existsSync } from "fs";
import { withAgentCwd } from "../bootstrap/cwd-context.ts";
import { withAgentCwd, getAgentRoot, splitIsolatedWorktreePath } from "../bootstrap/cwd-context.ts";
import { withIncrementedDepth, getAgentDepth, AGENT_DEPTH_ENV } from "./depth-context.ts";
import { runInSpanScope } from "../telemetry/span-scope.ts";
import {
Expand Down Expand Up @@ -284,6 +283,17 @@ export interface SubAgentResult {
apiRequests?: number;
}

/** SubagentStart 的工作目录 / 隔离字段(无绑定目录时为空对象,事件形状不变)。 */
function subAgentCwdFields(cwd: string | undefined): {
agentCwd?: string;
isolation?: "worktree";
} {
if (!cwd) return {};
return splitIsolatedWorktreePath(cwd)
? { agentCwd: cwd, isolation: "worktree" }
: { agentCwd: cwd };
}

/** 子代理系统提示词(从 AgentDefinition 注册表获取,兼容内置 + 自定义类型) */
function getSystemPrompt(type: string): string {
return (
Expand Down Expand Up @@ -361,7 +371,7 @@ async function enhanceSubAgentPrompt(
);

// 环境信息
const dir = workingDir ?? cwd();
const dir = workingDir ?? getCwd();
const home = homedir();
const os = platform();
const date = new Date().toISOString().split("T")[0];
Expand All @@ -378,15 +388,25 @@ async function enhanceSubAgentPrompt(
}

// D13:若工作目录落在隔离 worktree 内,明确告知子代理,避免它输出主仓路径或误判仓库状态。
if (dir.includes(`${sep}.sid-code${sep}worktrees${sep}`)) {
//
// ⚠️ `dir` 必须来自 getCwd()(ALS 优先)而不是 process.cwd():withAgentCwd 不 chdir,
// process.cwd() 在隔离子代理里恒为主仓根。旧实现四个调用点都传 process.cwd(),
// 于是「当前工作目录」写的是主仓、这条提示永远不出现 —— 2026-10-16~22 本机 11 个隔离
// 子代理 529 次带路径工具调用里 501 次打在主仓绝对路径上(轨迹实测)。
// 点名主仓根:只说「别用主仓路径」而不给出是哪个路径,模型无从判断自己写的是不是。
const isolated = splitIsolatedWorktreePath(dir);
if (isolated) {
notes.push(
isEn
? "[ISOLATED ENVIRONMENT] You are running inside an isolated Git worktree (a separate working tree sharing the main repo's object store). " +
"Your file changes affect only this working tree and will not pollute the main repo. Use the working directory above as the project root; " +
"do not assume you are in the main repository directory, and do not reference the main repo's absolute paths."
: "【隔离环境提示】你当前运行在一个隔离的 Git Worktree 中(独立工作区,与主仓共享对象库)。" +
"你的文件改动只影响此工作区,不会污染主仓。请使用上面的「当前工作目录」作为项目根," +
"不要假设自己在主仓库目录下,也不要引用主仓的绝对路径。",
? "[ISOLATED ENVIRONMENT] You are running inside an isolated Git worktree " +
`(root: ${isolated.worktreeRoot}; it shares the object store of the main repo at ${isolated.mainRepo}). ` +
"Use the worktree root as the project root: prefer relative paths, and prefix any absolute path with the worktree root. " +
`Never read or write paths under ${isolated.mainRepo} outside this worktree — writes there are rejected, ` +
"and reads see another checkout, not your branch."
: "【隔离环境提示】你当前运行在一个隔离的 Git Worktree 中" +
`(根目录: ${isolated.worktreeRoot};与位于 ${isolated.mainRepo} 的主仓共享对象库)。` +
"请以该 worktree 根作为项目根:优先使用相对路径,绝对路径一律以 worktree 根为前缀。" +
`不要读写 ${isolated.mainRepo} 下、本 worktree 之外的路径——写入会被拒绝,读到的也是另一份检出、不是你所在的分支。`,
);
}

Expand Down Expand Up @@ -450,6 +470,12 @@ export interface CustomSubAgentTask {
* 显式指定即开 thinking 并下发该族的线格式;不传则关 thinking。
*/
effort?: "low" | "medium" | "high" | "xhigh" | "max";
/**
* 工作目录(与 {@link SubAgentTask.cwd} 同语义)。设置时执行体包在 withAgentCwd 里、
* 强制进程内执行(ALS 不跨进程)。此前自定义路径没有这个字段:既不包 ALS、又优先 spawn
* (子进程 cwd 恒为父进程目录),想让 fork 命令 / agent hook 跑在隔离 worktree 里没有任何入口。
*/
cwd?: string;
/** E1(内部使用):executeCustom 分配的「类型 + 调用序号」后缀。三处(hook agent_id /
* 观测身份 / sidechain)共用它,才不会各自 ++_customAgentSeq 得出三个不同序号。
* 直接调 executeCustomInner / executeSpawnedCustom 的测试不传时各自现场分配。 */
Expand Down Expand Up @@ -591,9 +617,15 @@ export class SubAgent {
let queue: Promise<void> = Promise.resolve();

return (toolBlocks) => {
// JIT 的项目根 = 本子代理的隔离边界(withAgentCwd 进入时的 root,不随 cd 变),
// 无绑定时与主循环同口径取 process.cwd()(app.ts runJitDiscovery)。
// 旧实现取 process.cwd():隔离子代理里那是主仓根,相对路径被按主仓解析、
// 加载的是主仓那份规则文件(另一份检出),worktree 里的规则一份都进不来。
// 在调用时(仍处于子代理的异步上下文里)同步取值,不依赖队列回调里 ALS 的传播。
const projectRoot = getAgentRoot() ?? process.cwd();
const paths = collectJitAccessedPaths(
toolBlocks as Array<{ name: string; input: unknown }> as any,
process.cwd(),
projectRoot,
(name) => {
const tool = this.toolRegistry.get(name) as
| { jitAffectedPaths?: (input: unknown) => string[] }
Expand All @@ -608,8 +640,7 @@ export class SubAgent {
const log = getLogger();
for (const p of paths) {
try {
const cwd = process.cwd();
const r = await mgr.discoverDetailed(p, cwd);
const r = await mgr.discoverDetailed(p, projectRoot);

// 第 5 批:子代理侧也要打点。此前只有主循环打,子代理这条通道
// (P2-1 接的独立 manager)完全不进统计 —— 用到子代理的会话,
Expand All @@ -619,10 +650,11 @@ export class SubAgent {
emitJitEvent(
buildJitEventData({
accessedPath: p,
projectRoot: cwd,
projectRoot,
discovery: r,
cumulativeBytes: mgr.getLoadedBytes(),
source: "subagent",
isolatedWorktree: splitIsolatedWorktreePath(projectRoot) !== null,
}),
);

Expand Down Expand Up @@ -849,7 +881,7 @@ export class SubAgent {
sessionId: teammateSessionId,
pid: process.pid,
kind: "teammate",
cwd: task.cwd ?? process.cwd(),
cwd: task.cwd ?? getCwd(),
startedAt,
team: teamCtx.teamName,
model: task.model,
Expand All @@ -868,6 +900,7 @@ export class SubAgent {
description: task.description,
parentToolUseId: task._parentToolUseId,
legacyAgentId,
...subAgentCwdFields(task.cwd),
})
.catch((err) => log.error("HOOK", `subagent_start hook 失败: ${err.message}`));

Expand Down Expand Up @@ -992,13 +1025,20 @@ export class SubAgent {
?.fireSubagentStartEvent(customAgentId, "custom", this.parentSessionId, {
description: task.userPrompt?.slice(0, 120),
legacyAgentId: legacyCustomAgentId,
...subAgentCwdFields(task.cwd),
})
.catch((err) => log.error("HOOK", `subagent_start hook 失败: ${err.message}`));

// 缺陷 2:同 execute(),执行体进 span 作用域,内部 span 挂到本子代理下
result = await runInSpanScope(customAgentId, () =>
runWithHookAgent(hookScope, async () => {
const agentDefinition = resolveAgent(task.type ?? task.toolsFromAgentType ?? "");
if (task.cwd) {
const cwd = task.cwd;
return await withAgentCwd(cwd, () =>
this.executeCustomInner(customTask, signal, agentDefinition),
);
}
// 尝试 spawn 模式
if (this.shouldUseSpawn()) {
try {
Expand Down Expand Up @@ -1169,7 +1209,7 @@ export class SubAgent {
const systemPrompt = await enhanceSubAgentPrompt(
basePrompt,
this.language,
process.cwd(),
getCwd(),
task.type,
);
const toolDefs = this.getToolDefs(task);
Expand Down Expand Up @@ -1211,7 +1251,7 @@ export class SubAgent {
// 父进程在启动子进程前按 Agent 默认值与调用级 maxTurns 合并。
max_turns: spawnMaxTurns(maxTurns),
max_tokens: task.maxTokens ?? 50000,
workdir: process.cwd(),
workdir: getCwd(),
provider_name: providerName,
api_key: apiKey,
base_url: baseURL,
Expand Down Expand Up @@ -1246,7 +1286,7 @@ export class SubAgent {
const enhancedSystemPrompt = await enhanceSubAgentPrompt(
task.systemPrompt,
this.language,
process.cwd(),
getCwd(),
);
const tools = new ToolRegistry();
for (const t of this.resolveCustomTools(task)) tools.register(t);
Expand Down Expand Up @@ -1289,7 +1329,7 @@ export class SubAgent {
// 与进程内路径同口径:Agent 默认上限与任务级上限已在父进程合并。
max_turns: spawnMaxTurns(maxTurns),
max_tokens: task.maxTokens ?? 50000,
workdir: process.cwd(),
workdir: getCwd(),
provider_name: providerName,
api_key: apiKey,
base_url: baseURL,
Expand Down Expand Up @@ -1341,7 +1381,10 @@ export class SubAgent {
};
if (sidechain) {
try {
sidechain.start(sidechainMeta!.agentType, sidechainMeta!.description, initMsg.model);
sidechain.start(sidechainMeta!.agentType, sidechainMeta!.description, initMsg.model, {
systemPrompt: initMsg.system_prompt,
cwd: initMsg.workdir,
});
} catch {
/* 同上 */
}
Expand Down Expand Up @@ -1387,7 +1430,7 @@ export class SubAgent {
stdin: "pipe",
stdout: "pipe",
stderr: "inherit",
cwd: process.cwd(),
cwd: getCwd(),
// F8:把本子代理的深度带进子进程(此处已在 withIncrementedDepth 内,getAgentDepth()
// 即子代理自身深度),否则子进程从 0 起算,嵌套上限每跨一次进程就失效。
env: { ...process.env, [AGENT_DEPTH_ENV]: String(getAgentDepth()) },
Expand Down Expand Up @@ -1962,17 +2005,14 @@ export class SubAgent {
// 既占窗口又会诱导它去做计划里别的步骤。以后若要让子代理按主计划执行,
// 应显式传「本子任务对应的那一段」,而不是在这里补一行 provider。

// P2-10:落 sidechain_start(记录子代理身份,供恢复时展示)。
sidechain?.start(task.type, task.description, this.modelOverride || this.model);

const basePrompt = getSystemPrompt(task.type);
// P1-1:解析 agent 定义拿到 skills(预加载技能)。resolveAgent 覆盖 built-in + custom + plugin。
// 复用到下方 tool 过滤(agentDef.tools/disallowedTools),避免重复解析。
const agentDef = resolveAgent(task.type) ?? agentDefinition;
let systemPrompt = await enhanceSubAgentPrompt(
basePrompt,
this.language,
process.cwd(),
getCwd(),
task.type,
agentDef?.skills,
);
Expand All @@ -1984,6 +2024,12 @@ export class SubAgent {
systemPrompt += structuredOutputPromptSuffix();
}
ctxMgr.setSystemPrompt(systemPrompt);
// P2-10:落 sidechain_start(记录子代理身份,供恢复时展示)。放在提示词定稿之后,
// 让实际下发的系统提示词与工作目录一并落盘(之前子代理提示词在任何轨迹里都看不到)。
sidechain?.start(task.type, task.description, this.modelOverride || this.model, {
systemPrompt,
cwd: getCwd(),
});

// 添加任务提示。Fork 模式:先把继承自主对话的消息序列灌入上下文
// (buildForkMessages 已保证以 user 开头、只保留已配对的 tool 块),让子代理接续父对话;
Expand Down Expand Up @@ -2423,7 +2469,7 @@ export class SubAgent {
const systemPrompt = await enhanceSubAgentPrompt(
task.systemPrompt,
this.language,
process.cwd(),
getCwd(),
task.type,
);
ctxMgr.setSystemPrompt(systemPrompt);
Expand All @@ -2436,6 +2482,7 @@ export class SubAgent {
task.type ?? "custom",
task.userPrompt?.slice(0, 120) ?? "",
this.modelOverride || this.model,
{ systemPrompt, cwd: getCwd() },
);
} catch {
/* sidechain 落盘失败静默 */
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/bootstrap/cwd-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@
*/

import { AsyncLocalStorage } from "node:async_hooks";
import { sep } from "node:path";

/** 隔离 worktree 的固定落点:`<主仓>/.sid-code/worktrees/<name>`(见 worktree/manager.ts)。 */
const ISOLATED_WORKTREE_MARKER = `${sep}.sid-code${sep}worktrees${sep}`;

/**
* 把一个路径拆成「所属隔离 worktree 根 + 它的主仓根」;不在隔离 worktree 内返回 null。
*
* 曾经有三处各写一份 marker 判定(权限 W12、子代理提示词 D13、bash),口径一漂就是
* 「提示词说在隔离区、权限按主仓判」这类自相矛盾。统一到这里。
*/
export function splitIsolatedWorktreePath(
p: string,
): { worktreeRoot: string; mainRepo: string } | null {
const idx = p.indexOf(ISOLATED_WORKTREE_MARKER);
if (idx < 0) return null;
const nameStart = idx + ISOLATED_WORKTREE_MARKER.length;
const nameEnd = p.indexOf(sep, nameStart);
const worktreeRoot = nameEnd < 0 ? p : p.slice(0, nameEnd);
if (worktreeRoot.length === nameStart) return null; // `.../worktrees/` 本身不是某个 worktree
return { worktreeRoot, mainRepo: p.slice(0, idx) };
}

interface AgentCwdBox {
/** 进入 withAgentCwd 时绑定的目录(隔离边界,W12 用它判「写没写出自己的 worktree」) */
Expand All @@ -45,6 +67,15 @@ export function getAgentRoot(): string | undefined {
return cwdStorage.getStore()?.root;
}

/**
* 当前异步上下文是否绑定在某个隔离 worktree 上;是则返回其根与主仓根。
* 判据取进入时的 root(不随 cd 变),cd 出去不等于离开了隔离边界。
*/
export function getIsolatedWorktree(): { worktreeRoot: string; mainRepo: string } | null {
const root = getAgentRoot();
return root ? splitIsolatedWorktreePath(root) : null;
}

/**
* 在代理上下文内改写本上下文的 cwd。返回 true 表示已写入(调用方不要再碰全局 state.cwd);
* 不在任何 withAgentCwd 内时返回 false。
Expand Down
Loading
Loading