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 }); }