Skip to content

feat(data-layout): route managed-mcp.json + project resource cache to the partition (#374 P1-2C) - #417

Open
jeff-r2026 wants to merge 7 commits into
mainfrom
worktree-issue-374-p1-2c-managed-mcp-resources
Open

feat(data-layout): route managed-mcp.json + project resource cache to the partition (#374 P1-2C)#417
jeff-r2026 wants to merge 7 commits into
mainfrom
worktree-issue-374-p1-2c-managed-mcp-resources

Conversation

@jeff-r2026

Copy link
Copy Markdown
Collaborator

Context

Completes the P1-2 partition routing deferred from #406. Two A1 items still landed
in the business workspace for a project install; they now follow the
machine-data home so a partitioned install has zero teamai residue:

  • managed-mcp.json — the per-project MCP injection manifest.
  • project-scope local-agent/resources — the ~4MB skill cache the issue calls out.

(local-agent's own config/manifest are A2 — already global under ~/.teamai — and
are left untouched, per the issue's A1/A2 split.)

Why these were deferred (the desync trap)

Both are resolved from two sides: callers holding a full LocalConfig
(mcp-reconcile / mcp-cmd / uninstall) and the local-agent subsystem,
which only carries (scope, projectRoot) and has no LocalConfig. Converging one
side alone would make a partitioned install's writer and reader disagree
(partition vs legacy) and desync. So both must resolve through one source.

What this PR does

  • managedMcpManifestPath(dataHome) now takes a resolved data home;
    LocalConfig holders pass getDataHome(localConfig).
  • New resolveDataHomeForScope(scope, projectRoot) (config.ts) reproduces
    detection's double-read: user → ~/.teamai; project → getDataHome of
    detectProjectConfig's result (partition for a new/migrated install, else
    legacy <projectRoot>/.teamai). The local-agent managed-mcp + resource paths
    route through it, so both sides always land on the identical directory.
  • getResourceRepoPath (local-agent) is now async and uses the shared resolver
    for its project branch; user scope unchanged (~/.teamai/local-agent/resources).
  • ensureProjectGitignore runs only when the cache actually lands inside the
    workspace (a legacy install); a partitioned cache is under ~/.teamai, so
    there's nothing in the workspace to ignore.

Test plan (all executed green)

  • npx tsc --noEmit — clean
  • npx vitest run191 files / 2644 tests pass
  • New real git+fs integration tests (detect-subdir.test.ts) prove the
    desync guard: resolveDataHomeForScope == getDataHome(detectProjectConfig(...))
    for legacy, subdirectory, user, non-git, and partitioned installs
    (config in ~/.teamai/projects/<slug>/ resolves to the partition from
    both sides).

Note: the full http-backend local-agent install flow (install_skill/install_mcp
end-to-end) is covered by the 65 existing local-agent unit tests; a live-backend
e2e remains a manual TODO, as with the http provider.

Refs #374 (P1-2C).

… the partition (#374 P1-2C)

Completes the P1-2 partition routing deferred from #406: the two remaining A1
items that still landed in the business workspace for a project install now
follow the machine-data home.

- `managed-mcp.json` (per-project MCP injection manifest) and the project-scope
  `local-agent/resources` cache (the 4MB the issue calls out) move to
  `~/.teamai/projects/<slug>/` for a partitioned install, keeping the workspace
  clean. (local-agent's own config/manifest are A2 — already global under
  ~/.teamai — and are untouched.)

The trap these were deferred for: both are resolved from two sides — callers that
hold a full LocalConfig (mcp-reconcile / mcp-cmd / uninstall) and the local-agent
subsystem, which only carries (scope, projectRoot) and has no LocalConfig. If only
one side moved, a partitioned install's writer and reader would disagree
(partition vs legacy) and desync. Fix: one shared resolver.

- `managedMcpManifestPath(dataHome)` now takes a resolved data home. LocalConfig
  holders pass `getDataHome(localConfig)`.
- New `resolveDataHomeForScope(scope, projectRoot)` in config.ts reproduces
  detection's double-read (user → ~/.teamai; project → getDataHome of
  detectProjectConfig's result, i.e. partition for a new/migrated install, else
  legacy). The local-agent managed-mcp + resource paths route through it, so both
  sides always land on the identical directory.
- `getResourceRepoPath` (local-agent) is now async and uses the shared resolver
  for its project branch; user scope is unchanged (~/.teamai/local-agent/resources).
- `ensureProjectGitignore` now runs only when the cache actually lands inside the
  workspace (a legacy install) — a partitioned cache lives under ~/.teamai, so
  there is nothing in the workspace to ignore.

Test plan (all green):
- npx tsc --noEmit — clean; npx vitest run — 191 files / 2644 tests pass
- new real git+fs integration tests (detect-subdir.test.ts) prove the desync
  guard: `resolveDataHomeForScope` == `getDataHome(detectProjectConfig(...))` for
  legacy, subdirectory, user, non-git, AND partitioned installs (config in
  ~/.teamai/projects/<slug>/ resolves to the partition from both sides).

Note: the full http-backend local-agent install flow (install_skill/install_mcp
end-to-end) is covered by the 65 existing local-agent unit tests; a live-backend
e2e remains a manual TODO, as with the http provider.

Refs #374 (P1-2C).
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

[P1] managed-mcp.json is now shared across linked worktrees, but its ownership keys are not worktree-scoped.

The new partition is keyed by projectAnchor, so the main checkout and every linked worktree intentionally resolve to the same data home. However, project MCP files such as <workspace>/.mcp.json are still per-worktree, while the manifest key remains only claude:project (similarly for the other tools). This lets one worktree claim ownership of an entry in another worktree.

I reproduced this locally with a real git worktree add setup:

  1. Initialize a partitioned project and reconcile a team MCP named shared in the main worktree.
  2. In the linked worktree, manually create a user-owned shared MCP pointing to https://mine.example/mcp.
  3. Change the team's shared definition to https://team-v2.example/mcp and reconcile from the linked worktree.
  4. Reconcile reports action: updated and silently replaces the linked worktree's user-owned URL with the team URL, even though --force was not used.

The same shared ownership can also cause cross-worktree removal/orphaning when one worktree reconciles an empty desired set or uninstalls.

Please keep project MCP ownership isolated by canonical workspaceRoot: for example, store a per-worktree manifest inside the shared partition, or include a stable workspace identity in each project manifest key. The CLI reconcile/uninstall paths and the local-agent install/uninstall/report paths need to use the same identity. A real linked-worktree regression test should verify that a same-name user MCP in a sibling worktree is never updated or removed.

…-2C review)

The partition keys managed-mcp.json by projectAnchor, so the main checkout and
every linked worktree share ONE manifest. But a project MCP file
(`<workspace>/.mcp.json`, `.codex/config.toml`, …) is per-worktree, while the
manifest ownership key was only `<tool>:project`. So one worktree's reconcile /
uninstall claimed ownership of entries another worktree wrote into its OWN file —
silently overwriting (or, on an empty desired set, removing) a sibling worktree's
user-owned same-name MCP, without --force.

Fix: the project-scope manifest key now carries the current workspace identity.
New `managedMcpManifestKey(tool, projectScope, workspaceRoot)` in types.ts returns
`<tool>:project:<sha1(workspaceRoot)[:12]>` (user scope stays `<tool>`). Both the
CLI reconcile/uninstall path (mcp-reconcile: keyed off localConfig.projectRoot,
which detection anchors to the current worktree) and the local-agent
install/uninstall path (keyed off workspacePath) build the key through this one
helper, so the two sides always agree. The manifest still lives in the shared
partition; only ownership is now per-worktree.

Test plan (all green):
- npx tsc --noEmit — clean; npx vitest run — 191 files / 2645 tests pass
- NEW real-fs regression (mcp-reconcile.test.ts), reproducing the reviewer's
  scenario: two worktrees of one repo sharing one partition/manifest; reconcile
  `shared` v1 in worktree A; worktree B has a user-owned same-name `shared` →
  reconcile v2 from A only. A gets team-v2, B's `https://mine.example/mcp` is left
  untouched, and the manifest carries a distinct `claude:project:<id>` key per
  worktree.
- updated the local-agent-mcp install assertion to the workspace-scoped key.

Refs #374 (P1-2C).
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

确认成立,已修复(e2d5c74)。这是 P1-2C 把 manifest 搬进共享分区后暴露的所有权失配:分区按 projectAnchor 分,manifest 跨 worktree 共享,但 project MCP 文件(<workspace>/.mcp.json 等)是每 worktree 独立的,而所有权 key 只有 <tool>:project —— 于是一个 worktree 的 reconcile/uninstall 会认领并覆盖/删除另一 worktree 自有的同名 entry。

采纳你的方案,在 key 里加入当前 workspace 身份:新增 managedMcpManifestKey(tool, projectScope, workspaceRoot)(types.ts),project scope 返回 <tool>:project:<sha1(workspaceRoot)[:12]>。CLI reconcile/uninstall 侧(keyed off localConfig.projectRoot —— detection 已把它锚定到当前 worktree)与 local-agent install/uninstall 侧(keyed off workspacePath)都经这个同一 helper 构建 key,两侧一致。manifest 仍在共享分区,只是所有权按 worktree 隔离。

按你的要求补了真 linked-worktree 回归测试(mcp-reconcile.test.ts):两个 worktree 共享一个分区/manifest,worktree A reconcile shared v1;worktree B 有用户自有的同名 shared;仅从 A reconcile v2 → A 拿到 team-v2,B 的 https://mine.example/mcp 原封不动,manifest 里每 worktree 一个独立 claude:project:<id> key。2645 单测全绿。

@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

The original cross-worktree overwrite is fixed when I replay the actual trigger from worktree B. I found one new P1 upgrade regression and one smaller missed reader:

[P1] Migrate the existing bare project ownership keys

Every existing project-scope installation writes ownership under a bare key such as claude:project. The new code only reads claude:project:<workspace-hash>, with no compatibility read or migration.

I reproduced this locally using the pre-fix on-disk state:

  • <project>/.teamai/managed-mcp.json contains claude:project owning shared.
  • <project>/.mcp.json contains the TeamAI-injected v1 definition.
  • The team definition is changed to v2.

With the latest PR head, reconcile returns:

skipped — a server with this name already exists and is not managed by teamai

The v1 entry is not updated. Calling reconcile with removeAll: true then returns no changes and also leaves the entry in .mcp.json. A full uninstall will subsequently delete the old manifest while leaving the injected MCP server behind, permanently losing its ownership record.

Please migrate or compatibly consume the old <tool>:project records. Because a bare key is ambiguous in a shared partition, migration should only claim it when the old manifest is known to be worktree-local (for example, a legacy <workspace>/.teamai data home), or when the layout migration knows which workspace supplied it. A regression test should begin with the exact old manifest format and verify both update and remove/uninstall.

[P2] mcp list still reads the old key format

src/mcp-cmd.ts still checks:

manifest[`${t.tool}${t.projectScope ? ':project' : ''}`]

New project injections are stored under the hashed key, so teamai mcp list reports installed: (none) even after a successful project-scope injection. This reader should use managedMcpManifestKey(...) with the current localConfig.projectRoot as well.

One test note: the new sibling-worktree test reconciles v2 from worktree A, so B is untouched even without the fix, and its every(...) key assertion does not prove that two distinct keys exist. Replaying the reported scenario by reconciling from B does pass with the new helper; I recommend changing the regression to that exact trigger so it guards the ownership boundary directly.

… reader (#374 P1-2C review)

Two follow-ups from the #417 review of the per-worktree ownership key:

[P1] Legacy bare `<tool>:project` keys were neither read nor migrated. Every
pre-#374 project install owns its MCP entries under a bare `claude:project` key;
the new code only looked up `claude:project:<workspace-hash>`, so after upgrade
reconcile reported "a server with this name already exists and is not managed by
teamai" (no update), and uninstall would drop the manifest while leaving the
injected server behind — permanently losing the ownership record.

Fix: `resolveManagedMcpOwnership(manifest, tool, projectScope, workspaceRoot,
workspaceLocalManifest)` in types.ts. It returns the workspace-scoped key's
records, and — only when the manifest is workspace-local (a legacy
`<projectRoot>/.teamai` data home, where a bare key is unambiguous) — adopts an
old `<tool>:project` record into the workspace-scoped key and drops the bare key.
In the shared partition a bare key stays untouched (ambiguous; a future layout
migration that knows the source workspace can claim it). mcp-reconcile routes its
ownership read through this helper. uninstall already iterates ALL manifest keys,
so it collects bare + hashed records unchanged.

[P2] `teamai mcp list` still read `${tool}:project`, so a project injection stored
under the hashed key showed `installed: (none)`. It now uses
`managedMcpManifestKey(..., localConfig.projectRoot)`.

Test changes (reviewer notes both):
- Rewrote the sibling-worktree regression to replay the reported trigger —
  reconcile FROM worktree B while B has a user-owned same-name `shared` that A
  manages. Asserts B's server is preserved verbatim, the change is reported
  `skipped` (not `updated`), and A's ownership/file are unaffected. (The prior
  version reconciled from A, where B was untouched even without the fix.)
- New test starting from the exact pre-#374 on-disk format (bare `claude:project`
  + injected v1) verifies reconcile UPDATES to v2, migrates the bare key to the
  workspace-scoped key, and leaves no ambiguous key.

Test plan: npx tsc --noEmit clean; npx vitest run — 191 files / 2646 tests pass.

Refs #374 (P1-2C).
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

两条都确认成立,已修复(170c3ff);测试也按你的两点建议改了。

[P1] 旧 bare key 迁移/兼容读:老装的所有权在 claude:project(无 hash),新代码只读 hashed key → 升级后 reconcile 报 "already exists, not managed by teamai"(不更新),uninstall 会漏删已注入的 server、丢所有权。修复:新增 resolveManagedMcpOwnership(manifest, tool, projectScope, workspaceRoot, workspaceLocalManifest)(types.ts)。它返回 workspace-scoped key 的记录,并且仅当 manifest 是 workspace-local(legacy <projectRoot>/.teamai data home,bare key 无歧义)时,把旧 <tool>:project 记录迁移到 workspace-scoped key 并删掉 bare key。共享分区里 bare key 保持不动(有歧义,留给将来知道来源 workspace 的布局迁移认领)。mcp-reconcile 的所有权读走这个 helper;uninstall 本就遍历所有 key、bare+hashed 都会收集,无需改。

[P2] mcp list 仍读旧 key:已改为 managedMcpManifestKey(..., localConfig.projectRoot),project 注入不再显示 installed:(none)。

测试(采纳你两点):

  • 把 sibling-worktree 回归改成从 worktree B replay:B 有 A 所管理的同名 shared(user-owned from B's view),从 B reconcile v2 → 断言 B 的 server 逐字保留、change 报 skipped(非 updated)、A 的所有权/文件不受影响。(旧版从 A reconcile,B 本就不受影响,证不到边界。)
  • 新增测试从确切的 pre-Proposal: teamai 数据目录全局化 + 按项目分区(业务仓库工作区零残留) #374 磁盘格式起(bare claude:project + 已注入 v1):验证 reconcile 更新到 v2、把 bare key 迁移到 workspace-scoped key、不留歧义 key。

2646 单测全绿。

@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

The previous CLI legacy-key migration and mcp list issues are fixed. Two project-scope local-agent paths still do not use the new workspace identity consistently:

[P1] Local-agent install/uninstall does not adopt legacy bare project ownership

installMcpServer and uninstallMcpServer still read only:

const manifestKey = managedMcpManifestKey(tool, projectScope, workspacePath);
const owned = manifest[manifestKey] ?? [];

They never call resolveManagedMcpOwnership, so an MCP installed by an older local-agent under <tool>:project loses ownership after upgrade even when its manifest remains workspace-local and unambiguous.

I reproduced this with:

  • <workspace>/.teamai/managed-mcp.json containing codebuddy:project owning legacy;
  • <workspace>/.codebuddy/mcp.json containing that MCP;
  • a workspace-scoped uninstall_mcp command for legacy.

The command ACK is success, but the MCP remains unchanged:

{
  "ack": { "type": "uninstall_mcp", "status": "success" },
  "after": {
    "mcpServers": {
      "legacy": { "url": "https://old.example/mcp" }
    }
  }
}

The install/update path similarly treats the old TeamAI entry as unmanaged and fails. Please route both local-agent paths through the same compatibility helper and persist the safe workspace-local migration.

[P1] Project MCP reporting includes ownership from sibling worktrees

scanMcpFromManifest('project', projectRoot) opens the shared partition manifest and iterates every value in it. Now that the manifest contains per-worktree keys, it must filter to the current workspace key (and current tool as appropriate). Otherwise worktree B reports MCPs owned only by worktree A.

A minimal local repro placed a-only under a foreign codebuddy:project:<A-id> key and built the report for workspace B. B's payload incorrectly contained:

{
  "path": ".../repo-b",
  "mcps": [{ "slug": "a-only", "source": "enterprise" }]
}

This can make the backend believe B already has an MCP, suppress the required install, or issue an incorrect uninstall. Please make project reporting select only managedMcpManifestKey(tool, true, projectRoot) rather than flattening every worktree's records. A regression test should put different MCPs under A and B keys and assert each workspace reports only its own records.

…ugh workspace-scoped ownership (#374 P1-2C review)

The CLI reconcile/uninstall paths were fixed to use resolveManagedMcpOwnership +
per-worktree keys, but the local-agent subsystem's three project-MCP paths were
not — same class of bug the reviewer had flagged for the CLI side.

[P1] local-agent install/uninstall did not adopt legacy bare ownership.
installMcpServer/uninstallMcpServer read only `managedMcpManifestKey(...)`, so an
MCP an older local-agent installed under a bare `<tool>:project` key lost
ownership after upgrade (uninstall ack'd success but left the server; install
treated the old entry as unmanaged and failed). Both now go through
resolveManagedMcpOwnership with a workspace-local check (dataHome ===
<workspace>/.teamai), migrating the bare key only when unambiguous — identical to
the CLI reconcile path.

[P1] project MCP reporting leaked sibling-worktree ownership. scanMcpFromManifest
iterated EVERY manifest value; since the shared partition now holds per-worktree
keys, worktree B reported MCPs owned by worktree A (which could suppress a needed
install or trigger a wrong uninstall on the backend). It now filters to keys that
belong to this scope: project → keys carrying this workspace's id
(managedMcpWorkspaceId(projectRoot)), plus a legacy bare `<tool>:project` only for
a workspace-local manifest; user → bare `<tool>` keys only.

Added `managedMcpWorkspaceId(workspaceRoot)` to types.ts (the shared id segment)
so the key builder and the report filter derive the identity the same way.

Test plan (all green):
- npx tsc --noEmit clean; npx vitest run — 191 files / 2647 tests pass
- NEW real git-worktree report test: a shared partition manifest holds `a-only`
  under worktree A's key and `b-only` under worktree B's key; buildReportPayload
  for worktree B contains ONLY `b-only`, never `a-only`.
- the existing local-agent-mcp + mcp-reconcile suites (install/uninstall +
  legacy-migration + sibling-worktree) stay green.

Refs #374 (P1-2C).
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

两条都确认成立,已修复(5e97ec0)。这正是我上轮改了 CLI reconcile/uninstall 却漏掉 local-agent 子系统对应三处的同类遗漏。

[P1] local-agent install/uninstall 不兼容旧 bare key:installMcpServer/uninstallMcpServer 之前只查 managedMcpManifestKey(...)(hashed key),没走 resolveManagedMcpOwnership → 老 local-agent 装在 <tool>:project 的 entry 升级后丢所有权(uninstall ack success 但没删;install 当成 unmanaged 而失败)。现两处都改走 resolveManagedMcpOwnership,带 workspace-local 判断(dataHome === <workspace>/.teamai),仅在无歧义时迁移 bare key —— 与 CLI reconcile 侧一致。

[P1] project MCP 汇报串 worktree:scanMcpFromManifest 之前遍历所有 manifest value → 共享分区里 worktree B 会报 A 拥有的 MCP。现按 scope 过滤:project → 只选带当前 workspace id 的 key(managedMcpWorkspaceId(projectRoot)),外加 workspace-local manifest 的 legacy bare key;user → 只选裸 <tool> key。

新增 managedMcpWorkspaceId(workspaceRoot)(types.ts)作共享身份段,保证 key 构建与汇报过滤用同一算法。

测试(按你的要求):新增真 git-worktree 汇报测试 —— 共享分区 manifest 里 a-only 在 A 的 key、b-only 在 B 的 key,buildReportPayload for worktree B 只含 b-only、绝不含 a-only。既有 local-agent-mcp + mcp-reconcile 套件(install/uninstall + 旧格式迁移 + sibling-worktree)保持全绿。2647 单测通过。

@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

The previous local-agent ownership/report issues are fixed. One P1 remains now that managed-mcp.json is shared across worktrees:

[P1] Concurrent worktrees lose manifest ownership through read-modify-write

CLI reconcile and local-agent commands can operate on the same partition manifest concurrently. Each operation reads the whole JSON object, adds or removes its workspace key, then atomically replaces the whole file. Atomic rename prevents a partial file, but it does not prevent a lost update when both readers started from the same snapshot.

I reproduced this locally by running reconcileMcpForConfig concurrently for two workspace roots sharing one data home:

await Promise.all([
  reconcileMcpForConfig(teamConfig, cfgA),
  reconcileMcpForConfig(teamConfig, cfgB),
]);

Both worktree MCP files were written, but the shared manifest contained only one of the two expected ownership keys:

{
  "keys": ["claude:project:f837ecfc1203"]
}

The other worktree therefore has a TeamAI-injected MCP with no ownership record; later update/remove/uninstall treats it as user-owned or leaves it behind.

This does not require a large locking framework. The simplest design may be to store a separate manifest per worktree inside the partition, for example:

<partition>/workspaces/<workspace-id>/managed-mcp.json

Then CLI reconcile, local-agent install/uninstall, and reporting read only the current workspace's file, eliminating both write contention and key filtering. User scope can keep its existing global manifest. If retaining one shared JSON file is required, a small manifest-specific lock around the complete read → modify → write transaction is sufficient; all CLI and local-agent writers must use the same helper.

Please add a concurrent regression asserting that both workspace ownership records survive simultaneous reconcile operations.

…g shared-manifest lost updates (#374 P1-2C review)

The shared partition manifest still lost ownership records under concurrent
read-modify-write: two worktrees reconciling at once each read the same JSON
snapshot, added their key, and atomically replaced the whole file — atomic rename
prevents a torn file but not a lost update, so one worktree's ownership key was
dropped and its injected MCP became orphaned.

Fix (reviewer's preferred option): a manifest file PER WORKTREE at
`<partition>/workspaces/<workspaceId>/managed-mcp.json`. Simultaneous reconciles
now touch disjoint files, so neither concurrency loss nor cross-worktree bleed is
possible — and the ownership key drops back to a plain `<tool>:project` (no
workspace-id segment) with no key-filtering needed on read. User scope keeps its
single global `<dataHome>/managed-mcp.json`.

New `src/utils/mcp-manifest.ts` owns the per-worktree path + one-time compat
migration: on first read of a worktree's file, records belonging to this worktree
are lifted out of any legacy shared `<dataHome>/managed-mcp.json` (a bare
`<tool>:project` when the data home is workspace-local, or an interim
`<tool>:project:<thisId>` key) and written into the per-worktree file, then removed
from the shared file. All writers/readers route through it:
- mcp-reconcile: project → loadProjectMcpManifest; user → global file.
- local-agent install/uninstall + report scan: same, per worktree.
- uninstall + `mcp list`: read the scope-appropriate path.
`managedMcpManifestKey` simplified to `(tool, projectScope)`; the workspace-id and
resolveManagedMcpOwnership helpers are gone (folded into the per-worktree file +
migration).

Test plan (all green):
- npx tsc --noEmit clean; npx vitest run — 191 files / 2648 tests pass
- NEW concurrent regression (reviewer-requested): reconcile two worktrees sharing
  one partition via Promise.all → BOTH ownership records survive (each in its own
  per-worktree file) and both workspace .mcp.json get the server.
- rewrote the sibling-worktree + legacy-migration + local-agent install tests to
  the per-worktree file layout; the real git-worktree report test still isolates
  A vs B.

Refs #374 (P1-2C).
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

确认成立,已按你推荐的方案 a 修复(1fad7a9)。

根因:共享分区单文件在两个 worktree 并发 read-modify-write 时丢更新 —— 原子 rename 只防半写、不防「两者读同一快照 → 后写覆盖先写的 key」,一个 worktree 的所有权 key 被丢、注入的 MCP 变孤儿。

修复:project MCP manifest 改为每 worktree 一个文件 <partition>/workspaces/<workspaceId>/managed-mcp.json。并发 reconcile 触及不相交的文件 —— 既消除并发丢更新、也消除跨 worktree 串所有权;key 回退成普通 <tool>:project(无 workspace-id 段),读时不再需要过滤。user scope 保留全局单文件。

新增 src/utils/mcp-manifest.ts 统一 per-worktree 路径 + 一次性兼容迁移(首次读某 worktree 文件时,把属于它的记录从 legacy 共享文件里迁出:workspace-local 时认领 bare <tool>:project,或认领 <tool>:project:<本 worktree id>,然后从共享文件删除)。所有 writer/reader 都走它:mcp-reconcile、local-agent install/uninstall/report、uninstall、mcp listmanagedMcpManifestKey 简化为 (tool, projectScope),workspace-id key 与 resolveManagedMcpOwnership 已移除(其职责被 per-worktree 文件 + 迁移取代)。

测试:新增你要求的并发回归 —— 两个 worktree 共享一个分区、Promise.all 同时 reconcile → 两者的所有权记录都存活(各在自己的 per-worktree 文件),两个 workspace 的 .mcp.json 都拿到 server。sibling-worktree / legacy-migration / local-agent install 测试都改到新布局;真 git-worktree 汇报测试仍验证 A/B 隔离。2648 单测全绿。

@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

[P1] Persist the per-worktree manifest before removing migrated ownership from the shared file

loadProjectMcpManifest() currently removes matching records from the legacy shared manifest and writes that shared file at lines 58-61, but it only returns the migrated manifest in memory; it never persists manifestPath. This loses ownership whenever the caller is read-only or decides that no target file changed.

I reproduced this locally using the existing real-worktree report test: call buildReportPayload(...) twice for worktree B, whose interim shared-manifest key owns b-only. The first report contains b-only; the second report returns an empty MCP list:

AssertionError: expected [] to include 'b-only'

After the first call, the B key has been deleted from <dataHome>/managed-mcp.json, while <dataHome>/workspaces/<B-id>/managed-mcp.json does not exist. The same loss can happen during an unchanged reconcile: loadProjectMcpManifest() removes the source record, then reconcileMcpForConfig() skips writing because wrote === false. The injected MCP is now orphaned, so later updates/uninstall can treat it as unmanaged.

Please make migration durable before deleting the source record (atomically write the per-worktree destination first, then best-effort remove the shared key), or defer both migration writes to an explicit save/commit step. A regression test should run the report twice and assert that both reports contain b-only and that the per-worktree manifest exists after migration. Please also preserve dry-run semantics if migration remains inside the loader.

…igrated records (#374 P1-2C review)

loadProjectMcpManifest removed the claimed records from the legacy shared file and
rewrote that file, but never persisted the per-worktree DESTINATION — it only
returned the migrated manifest in memory. So whenever the caller was read-only
(report) or skipped its write (reconcile with wrote === false), the source record
was deleted while the destination file never appeared: the injected MCP became
orphaned. Reviewer reproduced it by running the report twice — the second call
returned an empty MCP list.

Fix: make migration durable inside the loader, independent of the caller. On a
first-read migration it now atomically writes the per-worktree destination FIRST,
then best-effort removes the claimed keys from the shared file. Destination-first
ordering means a crash between the two writes leaves the records readable in both
files (the per-worktree file wins), never in neither. Added a `dryRun` option that
suppresses both writes (preview), threaded from reconcile's options.

Test plan (all green):
- npx tsc --noEmit clean; npx vitest run — 191 files / 2648 tests pass
- extended the real git-worktree report test to run buildReportPayload TWICE:
  both reports contain `b-only`, and the per-worktree manifest file exists after
  the first (migrating) call — the exact durability repro from the review.

Refs #374 (P1-2C).
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

确认成立,是我这轮引入的真 bug,已修复(d945dcb)。loadProjectMcpManifest 从 legacy 共享文件删掉迁移记录并写回共享文件,却从没持久化 per-worktree 目标文件——只返回内存 manifest。于是当调用方只读(report)或不写盘(reconcile wrote===false)时,源记录已删、目标文件从没出现 → 注入的 MCP 变孤儿(正如你 report 跑两次的复现:第二次为空)。

修复:把迁移做成在 loader 内部持久、与调用方无关。首次读触发迁移时,先原子写 per-worktree 目标文件,成功后再 best-effort 删共享文件里的 claimed key。目标优先的顺序意味着两次写之间崩溃时记录在两个文件都可读(per-worktree 文件优先),绝不会两边都没有。另加 dryRun 选项(预览时不写任何文件),从 reconcile 的 options 透传。

测试:把真 git-worktree 汇报测试扩成跑两次 buildReportPayload —— 两次都含 b-only,且首次(触发迁移)后 per-worktree manifest 文件存在,正是你指出的 durability 复现。2648 单测全绿。

@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

[P1] Project uninstall deletes sibling-worktree ownership while leaving the sibling MCP installed

The new per-worktree manifests live below the shared project partition, but teamai uninstall still treats getDataHome(localConfig) as one removable home directory. Uninstall only reconciles MCP targets for the current projectRoot, then executeRemoval() deletes the entire shared partition. That removes every sibling worktree's manifest without touching the MCP config in those sibling worktrees.

I reproduced this locally with worktrees A and B sharing one dataHome:

  1. Put shared in both A/.mcp.json and B/.mcp.json.
  2. Put the corresponding ownership record in both per-worktree manifest paths.
  3. Run the real uninstall({ force: true }) flow with A as localConfig.projectRoot.
  4. A is cleaned, but afterward:
B/.mcp.json: shared server still present
<dataHome>/workspaces/<B-id>/managed-mcp.json: missing

The failing regression assertion is effectively:

expect((await fse.readJson(path.join(wtB, '.mcp.json'))).mcpServers.shared).toBeDefined();
expect(await fse.pathExists(managedMcpManifestPath(dataHome, wtB))).toBe(true);
// second assertion: expected false to be true

This leaves B's injected server permanently orphaned; later update/uninstall paths can no longer prove ownership and will treat it as user-managed. It also makes the operation internally inconsistent regardless of the intended uninstall scope.

Please define and implement one complete semantic before deleting the partition:

  • project-wide uninstall: enumerate linked worktrees, reconcile/remove their managed resources, then delete the shared partition; or
  • worktree-local detach: remove only the current worktree's resources/manifest and preserve the shared partition plus sibling manifests (with whatever state is needed to prevent immediate re-detection/reinstall).

A regression test should set up two worktree manifests, uninstall from A, and assert that B never ends in the invalid state "server present, ownership missing".

…ting the shared partition (#374 P1-2C review)

`teamai uninstall` (project scope) removed managed MCP servers only for the
current worktree, then executeRemoval deleted the WHOLE shared partition —
including every sibling worktree's per-worktree manifest. Each sibling was left
with a teamai-injected MCP server in its own <workspace>/.mcp.json whose ownership
record had just been deleted: permanently orphaned, and later update/uninstall
treats it as user-managed.

Fix (project-wide semantic): before executeRemoval deletes the partition, the MCP
cleanup now enumerates every linked worktree (new `listWorktrees()` in utils/git,
from `git worktree list --porcelain`, realpath'd) and runs a `removeAll` reconcile
for each — building a per-worktree LocalConfig (its projectRoot + resolved
dataHome) so each worktree's own manifest AND its .mcp.json are cleared. User
scope is unchanged (single global manifest → current config suffices).

Test plan (all green):
- npx tsc --noEmit clean; npx vitest run — 191 files / 2651 tests pass
- NEW regression (mcp-reconcile.test.ts): two worktrees sharing one partition,
  both with `shared` installed; run removeAll for BOTH (what uninstall now does);
  assert NEITHER worktree ends in "server present, ownership missing" — the
  server is gone from both .mcp.json and neither manifest still owns it.
- NEW listWorktrees tests (anchors.test.ts): lists main + linked worktree from a
  subdirectory; returns [] outside a git repo.

Refs #374 (P1-2C).
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

确认成立,已按 project-wide 语义修复(87f0be8)。teamai uninstall(project scope)之前只对当前 worktree 清 MCP,随后 executeRemoval 删掉整个共享分区 —— 连带删了所有 sibling worktree 的 per-worktree manifest,却没碰它们 .mcp.json 里已注入的 server,导致 sibling 的 server 永久孤儿。

采纳你给的方案一(project-wide uninstall):在 executeRemoval 删分区之前,MCP 清理阶段现在枚举所有 linked worktree(新增 listWorktrees(),基于 git worktree list --porcelain、realpath 归一),对每个 worktree 各跑一次 removeAll reconcile —— 用它自己的 projectRoot + 解析出的 dataHome 构造 per-worktree LocalConfig,把每个 worktree 自己的 manifest .mcp.json 都清干净。user scope 不变(单一全局 manifest,当前 config 足够)。

测试(你要求的):

  • 新增回归(mcp-reconcile.test.ts):两个 worktree 共享一个分区、都装了 shared,对两者都跑 removeAll(uninstall 现在的循环)→ 断言任一 worktree 都不处于「server 在、ownership 没了」的非法态(server 从两个 .mcp.json 都消失,两个 manifest 也都不再拥有它)。
  • 新增 listWorktrees 测试(anchors.test.ts):从子目录列出 main + linked worktree;非 git 目录返回 []。

2651 单测全绿。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant