fix(team-mcp): route agent teams mcp to app state

This commit is contained in:
777genius 2026-04-29 19:06:31 +03:00
parent bfd8b30ad5
commit 10188109eb
4 changed files with 34 additions and 5 deletions

View file

@ -1,6 +1,6 @@
# Member Work Sync Control Plane Plan
**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and readiness-gated Phase 2 nudge outbox/dispatcher/scheduler implemented
**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in Phase 2 nudge outbox/dispatcher/scheduler implemented
**Scope:** Team management, task work synchronization, agent work coordination
**Primary repo:** `claude_team`
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
@ -36,9 +36,10 @@ Current implementation note:
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
- Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states.
- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam gate and keeps UI/status reads passive.
- Dispatcher use case runs after queued reconcile and is also exposed through the facade. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
- Phase 2 nudge side effects are additionally disabled by default in production composition. Set `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1` only for isolated live validation. This keeps status/report/metrics active while guaranteeing that shadow-ready metrics cannot start inbox nudges by accident.
- Dispatcher use case can run after queued reconcile and is also exposed through the facade when nudge side effects are explicitly enabled. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
- Production busy revalidation is wired through a tool-activity busy signal adapter. Active or recently finished tool calls defer Phase 2 nudges instead of interrupting work.
- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams only. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write.
- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams only when nudge side effects are enabled. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write.
- Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts.
- Superseded-but-undelivered outbox items can be revived by a fresh queued reconcile for the same agenda fingerprint. Delivered nudges remain one-per-fingerprint.
- Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
@ -1363,7 +1364,7 @@ Expired leases are ignored by `SyncDecisionPolicy`.
### 10.4 Shadow Would-Nudge Semantics
Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send.
Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send. Production composition enforces this by default by not wiring `outboxStore`/`inboxNudge` unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1`.
`wouldNudge` is true only when all are true:

View file

@ -24556,7 +24556,11 @@ export class TeamProvisioningService {
throwIfCancelled();
child = spawnCli(launchSpec.command, launchSpec.args, {
cwd: launchSpec.cwd ?? cwd,
env: { ...env, ...launchSpec.env },
env: {
...env,
...launchSpec.env,
AGENT_TEAMS_MCP_CLAUDE_DIR: fixture.claudeDir,
},
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});

View file

@ -54,6 +54,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
let previousCliFlavor: string | undefined;
let previousControlUrl: string | undefined;
let previousNudgeFlag: string | undefined;
let previousCodexHome: string | undefined;
let codexHomeDir: string;
let svc: {
stopTeam(teamName: string): Promise<unknown>;
isTeamAlive(teamName: string): boolean;
@ -84,11 +86,17 @@ liveDescribe('Member work sync Codex live e2e', () => {
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED;
previousCodexHome = process.env.CODEX_HOME;
const codexHomeRoot = path.resolve('temp', 'member-work-sync-codex-live');
await fs.mkdir(codexHomeRoot, { recursive: true });
codexHomeDir = await fs.mkdtemp(path.join(codexHomeRoot, 'codex-home-'));
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0';
process.env.CODEX_HOME = codexHomeDir;
svc = null;
feature = null;
@ -108,11 +116,14 @@ liveDescribe('Member work sync Codex live e2e', () => {
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl);
restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag);
restoreEnv('CODEX_HOME', previousCodexHome);
setClaudeBasePathOverride(null);
if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') {
console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`);
console.info(`[MemberWorkSyncCodex.live] preserved CODEX_HOME: ${codexHomeDir}`);
} else {
await fs.rm(tempDir, { recursive: true, force: true });
await fs.rm(codexHomeDir, { recursive: true, force: true });
}
});

View file

@ -240,6 +240,19 @@ describe('TeamMcpConfigBuilder', () => {
expectTsxEntry(server, sourceEntry);
});
it('pins the MCP controller to the active Claude base path', async () => {
const claudeDir = path.join(tempAppData, 'custom-claude-root');
setClaudeBasePathOverride(claudeDir);
mockSourceWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const server = readGeneratedServer(configPath);
expect(server?.env?.AGENT_TEAMS_MCP_CLAUDE_DIR).toBe(claudeDir);
});
it('falls back to the built workspace MCP entry when source execution is unavailable', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();