From bc8fa27cf4305ecb73c9b389e19d24c80ab67d71 Mon Sep 17 00:00:00 2001 From: chengfushi <303623518@qq.com> Date: Fri, 31 Jul 2026 11:23:41 +0800 Subject: [PATCH] fix(sessions): preserve workspace bindings across frozen clones Preserve non-enumerable workspace CWD candidates when freezing shared Session populations and when the lead clones that population. This keeps Codex and Qoder workspace qualification stable without exposing private metadata in serialized output. Add regression coverage for frozen privacy and lead inventory reuse, plus a maintenance spec documenting the validation. Co-authored-by: Codex (GPT 5.6 Sol) --- ...eserve-frozen-session-workspace-binding.md | 59 +++++++++++++++++++ scripts/harness-analysis/task-loop-source.mjs | 5 +- scripts/session-analysis/provider-runner.mjs | 5 ++ .../session-analysis/session-population.mjs | 25 +++++++- test/session-population.test.mjs | 26 ++++++++ test/task-loop-source.test.mjs | 24 +++++--- 6 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 docs/specs/2026-07-31-preserve-frozen-session-workspace-binding.md diff --git a/docs/specs/2026-07-31-preserve-frozen-session-workspace-binding.md b/docs/specs/2026-07-31-preserve-frozen-session-workspace-binding.md new file mode 100644 index 0000000..598b175 --- /dev/null +++ b/docs/specs/2026-07-31-preserve-frozen-session-workspace-binding.md @@ -0,0 +1,59 @@ +# Preserve frozen Session workspace binding + +## Traceability + +- Spec ID: preserve-frozen-session-workspace-binding +- Status: Implemented + +## Intent + +Keep the workspace qualification metadata of eligible Sessions intact when an +evidence bundle freezes its shared Session population. This prevents a Session +that was already qualified for the requested workspace from being discarded +when the Session evidence and lead lanes consume the frozen population. + +## Acceptance Scenarios + +- AC-1: Freezing a Session with non-enumerable workspace CWD candidates + preserves those candidates on the frozen Session without making them part of + the public enumerable or serialized contract. +- AC-2: A Codex evidence bundle whose frozen population contains one eligible + workspace-qualified Session reports one eligible and selected Session in the + Session facts lane instead of failing with + `SESSION_POPULATION_BINDING_MISMATCH`. + +## Non-goals + +- Changing Session discovery, active-Session omission, time-window filtering, + workspace qualification policy, or population fingerprints. +- Making workspace CWD candidates enumerable or exposing them in report output. +- Changing renderer, finding, scoring, or report schemas. + +## Plan and Tasks + +1. Preserve the private workspace CWD candidates while cloning each Session in + `freezeSessionPopulation`. +2. Add a focused regression test proving the candidates survive freezing and + remain non-enumerable. +3. Run the Session population tests, evidence-bundle tests, full package tests, + and a real Codex evidence-bundle collection against the reproducing + workspace. + +## Test and Review Evidence + +- AC-1: `node --test test/session-population.test.mjs` +- AC-2: `node --test test/better-harness-evidence-bundle.test.mjs` +- Regression gate: `npm test` +- Real-path verification: run `harness evidence-bundle` for Codex against the + reproducing workspace and require `status: complete`, an available Session + evidence lane, and a bound Session population. +- Risk review: confirm workspace CWD candidates remain absent from + `Object.keys`, object spread, and `JSON.stringify` output. + +Observed evidence: + +- Focused Session population, task-loop source, and evidence-bundle tests: + 55 passed, 0 failed. +- Full package suite: 1019 passed, 0 failed. +- Real Codex evidence bundle: `complete`; Session and lead lanes `available`; + population binding `bound`; eligible and analyzed counts `1/1`. diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index 867d084..b3bde1f 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -21,6 +21,7 @@ import { projectCheckupReportEvidence } from "../coding-agent-practices/checkup/ import { buildTaskEpisodes, stableFingerprint } from "../session-analysis/episode-contract.mjs"; import { buildObservationManifest } from "../session-analysis/observation-manifest.mjs"; import { sanitizePrivateReviewText } from "../session-analysis/privacy-safe-text.mjs"; +import { cloneSessionWithWorkspaceCwds } from "../session-analysis/provider-runner.mjs"; import { sessionAnalysisRef } from "../session-analysis/session-ref.mjs"; import { bindSessionSelection, @@ -1102,7 +1103,9 @@ export async function createTaskLoopSourceFromSessions(options = {}) { ? sessionPopulationDiscovery(population) : await analyzer.analyze({ ...analyzerOptions, command: "sources" }); const inventorySource = population?.sessions ?? discovery.sessions; - const sessionInventory = Object.freeze(inventorySource.map((session) => Object.freeze(structuredClone(session)))); + const sessionInventory = Object.freeze( + inventorySource.map((session) => Object.freeze(cloneSessionWithWorkspaceCwds(session))), + ); if (selectionProfile) { assertSessionSelectionBinding(selectionProfile, selectionPlan, { eligibleCount: sessionInventory.length }); } diff --git a/scripts/session-analysis/provider-runner.mjs b/scripts/session-analysis/provider-runner.mjs index f01bdb9..4e306e4 100644 --- a/scripts/session-analysis/provider-runner.mjs +++ b/scripts/session-analysis/provider-runner.mjs @@ -83,6 +83,11 @@ export function sessionWorkspaceCwds(session) { return typeof explicit === "string" && explicit.length > 0 ? [explicit] : []; } +export function cloneSessionWithWorkspaceCwds(session) { + const clonedSession = structuredClone(session); + return bindSessionWorkspaceCwds(clonedSession, sessionWorkspaceCwds(session)); +} + export function sessionWorkspaceCwd(session, workspaceScope) { if (!workspaceScope) return sessionWorkspaceCwds(session)[0] ?? null; if (session?.workspaceMatch === WORKSPACE_SESSION_MATCH.DIRECT_CWD) { diff --git a/scripts/session-analysis/session-population.mjs b/scripts/session-analysis/session-population.mjs index 3878cb4..b9188a1 100644 --- a/scripts/session-analysis/session-population.mjs +++ b/scripts/session-analysis/session-population.mjs @@ -3,6 +3,11 @@ import { createFactsRunContext, prepareFactsSessionInventory, } from "./session-core-facts.mjs"; +import { + bindSessionWorkspaceCwds, + cloneSessionWithWorkspaceCwds, + sessionWorkspaceCwds, +} from "./provider-runner.mjs"; export const SESSION_POPULATION_BINDING_SCHEMA_VERSION = 1; export const SESSION_SELECTION_BINDING_SCHEMA_VERSION = 1; @@ -41,6 +46,14 @@ function bindingError(code, message) { return Object.assign(new Error(message), { code }); } +function freezeSession(session, inheritedWorkspaceCwds = []) { + const frozenCandidate = cloneSessionWithWorkspaceCwds(session); + if (sessionWorkspaceCwds(frozenCandidate).length === 0) { + bindSessionWorkspaceCwds(frozenCandidate, inheritedWorkspaceCwds); + } + return Object.freeze(frozenCandidate); +} + export function freezeSessionPopulation({ scope = {}, sessions = [], @@ -58,8 +71,16 @@ export function freezeSessionPopulation({ ...(excludedSessionId ? { "exclude-session-id": excludedSessionId } : {}), _factsStartedAt: startedAt, }, platform, providerSessionId); - const prepared = prepareFactsSessionInventory(rows(sessions), factsContext); - const frozenSessions = Object.freeze(prepared.sessions.map((session) => Object.freeze(structuredClone(session)))); + const sourceSessions = rows(sessions); + const workspaceCwdsBySessionId = new Map(sourceSessions.flatMap((session) => { + const sessionId = String(session?.sessionId ?? "").trim(); + return sessionId ? [[sessionId, sessionWorkspaceCwds(session)]] : []; + })); + const prepared = prepareFactsSessionInventory(sourceSessions, factsContext); + const frozenSessions = Object.freeze(prepared.sessions.map((session) => { + const sessionId = String(session?.sessionId ?? "").trim(); + return freezeSession(session, sessionId ? workspaceCwdsBySessionId.get(sessionId) : []); + })); const ids = sessionIds(frozenSessions); const population = { sessions: frozenSessions, diff --git a/test/session-population.test.mjs b/test/session-population.test.mjs index 1f30f78..b23094f 100644 --- a/test/session-population.test.mjs +++ b/test/session-population.test.mjs @@ -5,6 +5,10 @@ async function populationModule() { return import("../scripts/session-analysis/session-population.mjs"); } +async function workspaceModule() { + return import("../scripts/session-analysis/provider-runner.mjs"); +} + test("frozen population binding omits exact active and Qoder home-only sessions", async () => { const { freezeSessionPopulation } = await populationModule(); const population = freezeSessionPopulation({ @@ -33,6 +37,28 @@ test("frozen population binding omits exact active and Qoder home-only sessions" assert.doesNotMatch(JSON.stringify(population.binding), /active-private|home-private|eligible-private|\/private/u); }); +test("frozen population preserves private Session workspace CWD candidates", async () => { + const { freezeSessionPopulation } = await populationModule(); + const { bindSessionWorkspaceCwds, sessionWorkspaceCwds } = await workspaceModule(); + const session = bindSessionWorkspaceCwds( + { sessionId: "eligible" }, + ["/workspace", "/workspace/member"], + ); + + const population = freezeSessionPopulation({ + scope: { platform: "codex", workspace: "/workspace", until: "2026-07-30T00:00:00.000Z" }, + sessions: [session], + suppliedUntil: true, + }); + const [frozenSession] = population.sessions; + + assert.deepEqual(sessionWorkspaceCwds(frozenSession), ["/workspace", "/workspace/member"]); + assert.deepEqual(Object.keys(frozenSession), ["sessionId"]); + assert.deepEqual({ ...frozenSession }, { sessionId: "eligible" }); + assert.equal(JSON.stringify(frozenSession), "{\"sessionId\":\"eligible\"}"); + assert.equal(Object.isFrozen(frozenSession), true); +}); + test("selection binding rejects a session outside the frozen population", async () => { const { bindSessionSelection, freezeSessionPopulation } = await populationModule(); const population = freezeSessionPopulation({ diff --git a/test/task-loop-source.test.mjs b/test/task-loop-source.test.mjs index cb66352..2dfbcbc 100644 --- a/test/task-loop-source.test.mjs +++ b/test/task-loop-source.test.mjs @@ -6,6 +6,10 @@ import test from "node:test"; import { buildCheckupScan } from "../scripts/coding-agent-practices/checkup/scan.mjs"; import { freezeSessionPopulation } from "../scripts/session-analysis/session-population.mjs"; +import { + bindSessionWorkspaceCwds, + sessionWorkspaceCwds, +} from "../scripts/session-analysis/provider-runner.mjs"; import { buildHarnessReviewPacket, validateHarnessReviewPacket, @@ -401,13 +405,14 @@ test("requested usage reuses one frozen population without rediscovery and emits const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-frozen-usage-")); const workspace = path.join(root, "workspace"); const calls = []; - const initialSessions = ["session-a", "session-b"].map((sessionId) => ({ - sessionId, - firstSeen: "2026-07-17T08:00:00.000Z", - lastSeen: "2026-07-17T08:05:00.000Z", - sourceKinds: ["fixture"], - sourceRefs: [{ kind: "project-session", path: "/fixture/session.jsonl" }], - })); + const initialSessions = ["session-a", "session-b"].map((sessionId) => + bindSessionWorkspaceCwds({ + sessionId, + firstSeen: "2026-07-17T08:00:00.000Z", + lastSeen: "2026-07-17T08:05:00.000Z", + sourceKinds: ["fixture"], + sourceRefs: [{ kind: "project-session", path: "/fixture/session.jsonl" }], + }, [workspace])); const activity = { schemaVersion: 1, dateBasis: "UTC", @@ -449,6 +454,7 @@ test("requested usage reuses one frozen population without rediscovery and emits until: options.until, piHome: options.piHome, inventory: options.sessionInventory?.map((session) => session.sessionId) ?? null, + workspaceCwds: options.sessionInventory?.map(sessionWorkspaceCwds) ?? null, }); if (options.command === "sources") { return { @@ -524,6 +530,10 @@ test("requested usage reuses one frozen population without rediscovery and emits ["session-a", "session-b"], ["session-a", "session-b"], ]); + assert.deepEqual(calls.filter((call) => call.command === "insights").map((call) => call.workspaceCwds), [ + [[workspace], [workspace]], + [[workspace], [workspace]], + ]); } finally { await rm(root, { recursive: true, force: true }); }