{"record":{"id":"eff8b5d606061aeb","repo":"Yeachan-Heo/oh-my-codex","slug":"canonical-scale-down-membership-changed","errorCode":null,"errorMessage":"canonical_scale_down_membership_changed","messagePattern":"canonical_scale_down_membership_changed","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"warning","filePath":"src/team/scaling.ts","lineNumber":2070,"sourceCode":"        if (allDrained.every(Boolean)) break;\n        await new Promise(r => setTimeout(r, 2_000));\n      }\n    }\n\n    // Phase 3: acquire the membership barrier and task claim locks before any\n    // pane effect. The barrier and locks remain held through the canonical\n    // snapshot, exact pane teardown, and forward-recoverable commit.\n    let removableWorkers = targetWorkers;\n    let removableWorkerNames = new Set(removableWorkers.map((worker) => worker.name));\n    let teardownFailure: ScaleError | null = null;\n    try {\n      await withTaskMembershipBarrier(sanitized, leaderCwd, async () => {\n        await recoverTeamMembershipTaskTransaction(sanitized, leaderCwd);\n        const authoritativeConfig = await readTeamConfig(sanitized, leaderCwd);\n        if (!authoritativeConfig) throw new Error('canonical_scale_down_config_missing');\n        const authoritativeWorkers = removableWorkers.map((worker) => authoritativeConfig.workers.find((candidate) => candidate.name === worker.name));\n        if (authoritativeWorkers.some((worker): worker is undefined => !worker)) {\n          throw new Error('canonical_scale_down_membership_changed');\n        }\n        // Capture targets from the canonical generation while the membership\n        // authority is held; no caller-owned worker record authorizes a pane effect.\n        removableWorkers = authoritativeWorkers as WorkerInfo[];\n        removableWorkerNames = new Set(removableWorkers.map((worker) => worker.name));\n        teamStateRoot = authoritativeConfig.team_state_root ?? resolveCanonicalTeamStateRoot(leaderCwd);\n        Object.assign(config, authoritativeConfig);\n        const candidateTaskIds = (await listTasks(sanitized, leaderCwd))\n          .filter((task) => task.status !== 'completed' && task.status !== 'failed')\n          .map((task) => task.id);\n        await withTaskClaimLocks(sanitized, candidateTaskIds, leaderCwd, async () => {\n          const lockedTasks = await listTasks(sanitized, leaderCwd);\n          const configPath = join(teamStateRoot, 'team', sanitized, 'config.json');\n          const configSnapshot = await readFile(configPath);\n          const manifestPath = join(teamStateRoot, 'team', sanitized, 'manifest.v2.json');\n          const manifestSnapshot = existsSync(manifestPath) ? await readFile(manifestPath) : null;\n          const reconciledTasks = lockedTasks.filter((task) => task.status !== 'completed' && task.status !== 'failed'\n            && (removableWorkerNames.has(task.owner ?? '') || removableWorkerNames.has(task.claim?.owner ?? '')));","sourceCodeStart":2052,"sourceCodeEnd":2088,"githubUrl":"https://github.com/Yeachan-Heo/oh-my-codex/blob/3ad79a8a6fe6e95fdbb8c00e40716fffe4011ce2/src/team/scaling.ts#L2052-L2088","documentation":"The canonical team config read during scale-down no longer contains one or more of the workers requested for removal, meaning membership changed between caller capture and the barrier-held read. Scale-down aborts rather than tearing down stale targets.","triggerScenarios":"Calling scaleDown with worker records captured earlier while another process already removed some of those workers from the config.","commonSituations":"Concurrent scale-down operations racing; UI showing stale worker list; retrying a scaleDown whose first attempt actually succeeded.","solutions":["Re-read the current team membership (team status) and retry scaleDown with workers that still exist","Serialize team operations so only one scaler runs at a time (lock or CLI-level queue)","Treat this error as success if the goal (worker removal) is already achieved"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"const config = await readTeamConfig(team, cwd);\nconst targets = workers.filter((w) => config?.workers.some((c) => c.name === w.name));\nif (targets.length === 0) return; // already removed","typeGuard":null,"tryCatchPattern":"catch (e) {\n  if ((e as Error).message === 'canonical_scale_down_membership_changed') {\n    return; // someone else already removed them; idempotent success\n  }\n  throw e;\n}","preventionTips":["Re-fetch membership immediately before each scale-down","Serialize scale operations per team"],"tags":["team","scale-down","membership","concurrency"],"backgroundTag":"stale-state-conflict","analyzedSha":"3ad79a8a6fe6e95fdbb8c00e40716fffe4011ce2","analyzedAt":"2026-08-27T22:18:39.783Z","schemaVersion":2},"datasetVersion":"2026-08-28T00:17:15.603Z"}