diff --git a/docs/team-management/member-work-sync-control-plane-plan.md b/docs/team-management/member-work-sync-control-plane-plan.md index 46f0aa61..218314d2 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -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: diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b0027afd..ca4d17db 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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, }); diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts index 9664a531..3e416fe3 100644 --- a/test/main/services/team/MemberWorkSyncCodex.live.test.ts +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -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; 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 }); } }); diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 91f68eaa..273b2c82 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -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();