diff --git a/packages/coding-agent/changelog.d/task-owner-cleanup-helper.md b/packages/coding-agent/changelog.d/task-owner-cleanup-helper.md new file mode 100644 index 00000000000..618f6c2f0fc --- /dev/null +++ b/packages/coding-agent/changelog.d/task-owner-cleanup-helper.md @@ -0,0 +1,2 @@ +### Added +- Add complete managed logical-owner cleanup coordination with live scope/protocol fencing, immutable receipt evidence, actual native outcomes and fail-closed continuation handling. Producer/admission activation and managed/SDK/GC caller installation remain separate changes. diff --git a/packages/coding-agent/src/session/internal/managed-task-owner-cleanup.ts b/packages/coding-agent/src/session/internal/managed-task-owner-cleanup.ts new file mode 100644 index 00000000000..41f550312d6 --- /dev/null +++ b/packages/coding-agent/src/session/internal/managed-task-owner-cleanup.ts @@ -0,0 +1,472 @@ +import * as path from "node:path"; +import * as util from "node:util"; +import type { ResumeSessionIdentity } from "../session-manager"; +import { + FileSessionStorage, + type SessionStorageSnapshot, + type VerifiedSessionDeleteResult, + type VerifiedSessionDeleteTarget, +} from "../session-storage"; +import type { TaskArtifactOwnerDeletionEvidence, TaskArtifactOwnerStorageContext } from "../task-artifact-owner-codec"; +import { + parseTaskArtifactOwnerDeletionEvidence, + parseTaskArtifactOwnerRetirementOutcome, +} from "../task-artifact-owner-codec"; +import { retireTaskArtifactOwner, verifyTaskArtifactOwnerPhysicalRetirement } from "../task-artifact-owner-retirement"; +import type { + ManagedGcSessionRetirementReceipt, + ManagedGcSessionRetirementTarget, +} from "./managed-gc-retirement-codec"; +import type { ManagedGcProtocolScopeInspector } from "./managed-session-scope"; +import { captureTaskArtifactOwnerDeletionEvidence } from "./task-artifact-owner-access"; +import { + hasSiblingTaskArtifactOwnerTranscript, + taskArtifactOwnerLocatorFromTranscriptBytes, +} from "./task-artifact-owner-transcript"; + +export interface ManagedGcOwnerCleanupTarget { + readonly path: string; + readonly sessionId: string; + readonly cwd: string; + readonly identity: ResumeSessionIdentity; + readonly taskArtifactOwnerDeletionEvidence?: TaskArtifactOwnerDeletionEvidence; +} + +/** Authenticated receipt operations and fencing supplied by the prepared managed scope. */ +export interface ManagedGcOwnerCleanupAuthority { + readonly agentDir: string; + readonly sessionsRoot: string; + readonly directoryPath: string; + readonly storageContext: TaskArtifactOwnerStorageContext; + readonly inspectProtocol: ManagedGcProtocolScopeInspector; + readonly assertOwned: () => void; + readonly bindTarget: (transcriptPath: string) => ManagedGcSessionRetirementTarget; + readonly readReceipt: (transcriptPath: string) => Promise; + readonly publishReceipt: (receipt: ManagedGcSessionRetirementReceipt) => Promise; + readonly findCompletedRetirement: ( + evidence: TaskArtifactOwnerDeletionEvidence, + exceptTranscriptPath: string, + ) => Promise< + | { + scope: { readonly agentDir: string; readonly sessionsRoot: string; readonly directoryPath: string }; + receipt: ManagedGcSessionRetirementReceipt; + } + | undefined + >; +} + +export type ManagedGcOwnerProgress = { + readonly state: "none" | "owner_retired" | "payload_retired" | "pending"; + readonly message?: string; +}; + +export type ManagedGcOwnerDeleteFields = Pick< + VerifiedSessionDeleteTarget, + | "taskArtifactOwnerStorageContext" + | "taskArtifactOwnerDeletionEvidence" + | "taskArtifactOwnerRetirementOutcome" + | "taskArtifactOwnerRetirementContinuation" + | "taskArtifactOwnerPayloadRetired" + | "taskArtifactOwnerNamespaceRetained" + | "deferTaskArtifactOwnerRetirement" + | "taskArtifactOwnerRetired" +>; + +function deepSame(left: unknown, right: unknown): boolean { + return util.isDeepStrictEqual(left, right); +} + +function managedGcTargetMatchesCandidate( + target: ManagedGcSessionRetirementTarget, + candidate: ManagedGcOwnerCleanupTarget, +): boolean { + return ( + target.transcriptPath === candidate.path && + target.sessionId === candidate.sessionId && + target.cwd === candidate.cwd && + target.transcriptIdentity.nlink === 1n && + target.transcriptIdentity.dev === candidate.identity.dev && + target.transcriptIdentity.ino === candidate.identity.ino && + target.transcriptIdentity.size === candidate.identity.size && + target.transcriptIdentity.mtimeNs === candidate.identity.mtimeNs && + target.transcriptIdentity.sha256 === candidate.identity.sha256 + ); +} + +function managedGcReceiptTarget(receipt: ManagedGcSessionRetirementReceipt): ManagedGcSessionRetirementTarget { + return { + transcriptPath: receipt.transcriptPath, + sessionId: receipt.sessionId, + cwd: receipt.cwd, + transcriptIdentity: receipt.transcriptIdentity, + taskArtifactOwnerLocator: receipt.taskArtifactOwnerDeletionEvidence.locator, + }; +} + +export function hasUnsupportedLegacyOwnerTarget(scopeDirectory: string, target: ManagedGcOwnerCleanupTarget): boolean { + if (path.dirname(target.path) === scopeDirectory) return false; + if (target.taskArtifactOwnerDeletionEvidence) return true; + let snapshot: SessionStorageSnapshot; + try { + snapshot = new FileSessionStorage().readSnapshotSync(target.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + return taskArtifactOwnerLocatorFromTranscriptBytes(snapshot.bytes, target.sessionId) !== undefined; +} + +export async function prepareManagedGcOwnerTarget( + authority: ManagedGcOwnerCleanupAuthority, + target: T, +): Promise { + if (path.dirname(target.path) !== authority.directoryPath) { + if (hasUnsupportedLegacyOwnerTarget(authority.directoryPath, target)) + throw new Error("task_artifact_owner_legacy_scope_unsupported"); + return target; + } + const receipt = await authority.readReceipt(target.path); + if (receipt) { + if ( + !managedGcTargetMatchesCandidate(receipt, target) || + (target.taskArtifactOwnerDeletionEvidence && + !deepSame(target.taskArtifactOwnerDeletionEvidence, receipt.taskArtifactOwnerDeletionEvidence)) + ) + throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + return { ...target, taskArtifactOwnerDeletionEvidence: receipt.taskArtifactOwnerDeletionEvidence }; + } + if (target.taskArtifactOwnerDeletionEvidence) throw new Error("task_artifact_owner_continuation_state_missing"); + let snapshot: SessionStorageSnapshot; + try { + snapshot = new FileSessionStorage().readSnapshotSync(target.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return target; + throw error; + } + const locator = taskArtifactOwnerLocatorFromTranscriptBytes(snapshot.bytes, target.sessionId); + if (!locator) return target; + const bound = authority.bindTarget(target.path); + if (!managedGcTargetMatchesCandidate(bound, target) || !deepSame(locator, bound.taskArtifactOwnerLocator)) + throw new Error("task_artifact_owner_continuation_transcript_replaced"); + const evidence = captureTaskArtifactOwnerDeletionEvidence(authority.storageContext, target.sessionId, locator); + if (!evidence) throw new Error("task_artifact_owner_deletion_evidence_missing"); + const prepared = await authority.publishReceipt({ + ...bound, + state: "prepared", + taskArtifactOwnerDeletionEvidence: evidence, + }); + if ( + !deepSame(prepared.taskArtifactOwnerDeletionEvidence, evidence) || + !managedGcTargetMatchesCandidate(prepared, target) + ) + throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + return { ...target, taskArtifactOwnerDeletionEvidence: evidence }; +} + +export async function prepareManagedGcOwnerTargets( + authority: ManagedGcOwnerCleanupAuthority, + targets: readonly T[], +): Promise<(T & ManagedGcOwnerCleanupTarget)[]> { + const prepared: (T & ManagedGcOwnerCleanupTarget)[] = []; + for (const target of targets) prepared.push(await prepareManagedGcOwnerTarget(authority, target)); + return prepared; +} + +export async function managedGcOwnerDeleteFields( + authority: ManagedGcOwnerCleanupAuthority, + target: ManagedGcOwnerCleanupTarget, +): Promise { + if (path.dirname(target.path) !== authority.directoryPath) { + if (hasUnsupportedLegacyOwnerTarget(authority.directoryPath, target)) + throw new Error("task_artifact_owner_legacy_scope_unsupported"); + return {}; + } + const receipt = await authority.readReceipt(target.path); + if (!receipt) { + if (target.taskArtifactOwnerDeletionEvidence) throw new Error("task_artifact_owner_continuation_state_missing"); + return {}; + } + if ( + !managedGcTargetMatchesCandidate(receipt, target) || + !deepSame(receipt.taskArtifactOwnerDeletionEvidence, target.taskArtifactOwnerDeletionEvidence) + ) + throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + const outcome = receipt.taskArtifactOwnerRetirementOutcome; + return { + taskArtifactOwnerStorageContext: authority.storageContext, + taskArtifactOwnerDeletionEvidence: receipt.taskArtifactOwnerDeletionEvidence, + deferTaskArtifactOwnerRetirement: true, + ...(outcome ? { taskArtifactOwnerRetirementOutcome: outcome } : {}), + ...(outcome && outcome.kind !== "completed" + ? { taskArtifactOwnerRetirementContinuation: outcome.continuation } + : {}), + ...(outcome?.kind === "payload_retired" + ? { taskArtifactOwnerPayloadRetired: true as const, taskArtifactOwnerNamespaceRetained: true as const } + : {}), + ...(receipt.state === "owner_retired" ? { taskArtifactOwnerRetired: true as const } : {}), + }; +} + +export async function publishManagedGcArtifactsRemoved( + authority: ManagedGcOwnerCleanupAuthority, + target: ManagedGcOwnerCleanupTarget, +): Promise { + if (!target.taskArtifactOwnerDeletionEvidence) return undefined; + const receipt = await authority.readReceipt(target.path); + if (!receipt) throw new Error("task_artifact_owner_continuation_state_missing"); + if (!deepSame(receipt.taskArtifactOwnerDeletionEvidence, target.taskArtifactOwnerDeletionEvidence)) + throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + if (receipt.state !== "prepared") return receipt; + return authority.publishReceipt({ + ...managedGcReceiptTarget(receipt), + state: "artifacts_removed", + taskArtifactOwnerDeletionEvidence: receipt.taskArtifactOwnerDeletionEvidence, + artifactsRemoved: true, + }); +} + +export async function retireManagedGcOwnerAfterArtifacts( + authority: ManagedGcOwnerCleanupAuthority, + target: ManagedGcOwnerCleanupTarget, + tombstoneTargets: readonly ManagedGcOwnerCleanupTarget[], +): Promise { + if (!target.taskArtifactOwnerDeletionEvidence) return { state: "none" }; + const receipt = await publishManagedGcArtifactsRemoved(authority, target); + if (!receipt) return { state: "none" }; + const evidence = receipt.taskArtifactOwnerDeletionEvidence; + if (!evidence) throw new Error("task_artifact_owner_continuation_evidence_missing"); + authority.assertOwned(); + const sharedBefore = await hasSiblingTaskArtifactOwnerTranscript( + new FileSessionStorage(), + target.path, + evidence.locator, + authority.storageContext, + authority.inspectProtocol, + ); + authority.assertOwned(); + if (sharedBefore) return { state: "pending", message: "task_artifact_owner_shared_with_sibling_transcript" }; + if (receipt.state === "owner_retired") return { state: "owner_retired" }; + for (const sibling of tombstoneTargets) { + if ( + sibling.path === target.path || + !sibling.taskArtifactOwnerDeletionEvidence || + !deepSame(sibling.taskArtifactOwnerDeletionEvidence, evidence) + ) + continue; + const siblingReceipt = await authority.readReceipt(sibling.path); + const siblingOutcome = siblingReceipt?.taskArtifactOwnerRetirementOutcome; + if ( + siblingReceipt?.state !== "owner_retired" || + siblingOutcome?.kind !== "completed" || + !deepSame(siblingReceipt.taskArtifactOwnerDeletionEvidence, evidence) + ) + continue; + const inherited = await authority.publishReceipt({ + ...managedGcReceiptTarget(receipt), + state: "owner_retired", + taskArtifactOwnerDeletionEvidence: evidence, + artifactsRemoved: true, + taskArtifactOwnerRetirementOutcome: siblingOutcome, + taskArtifactOwnerRetired: true, + }); + if (inherited.state !== "owner_retired") throw new Error("task_artifact_owner_inheritance_pending"); + return { state: "owner_retired" }; + } + const inherited = await authority.findCompletedRetirement(evidence, target.path); + if (inherited) { + if ( + inherited.scope.agentDir !== authority.agentDir || + inherited.scope.sessionsRoot !== authority.sessionsRoot || + inherited.scope.directoryPath === authority.directoryPath || + path.dirname(inherited.receipt.transcriptPath) !== inherited.scope.directoryPath || + inherited.receipt.transcriptPath === target.path || + inherited.receipt.state !== "owner_retired" + ) + throw new Error("task_artifact_owner_continuation_authority_mismatch"); + const inheritedEvidence = parseTaskArtifactOwnerDeletionEvidence( + inherited.receipt.taskArtifactOwnerDeletionEvidence, + ); + if (!deepSame(inheritedEvidence, evidence)) throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + const inheritedOutcome = parseTaskArtifactOwnerRetirementOutcome( + authority.storageContext, + evidence, + inherited.receipt.taskArtifactOwnerRetirementOutcome, + ); + if (inheritedOutcome.kind !== "completed") throw new Error("task_artifact_owner_physical_retirement_unverified"); + verifyTaskArtifactOwnerPhysicalRetirement(authority.storageContext, evidence, inheritedOutcome); + authority.assertOwned(); + const sharedAfterInheritance = await hasSiblingTaskArtifactOwnerTranscript( + new FileSessionStorage(), + target.path, + evidence.locator, + authority.storageContext, + authority.inspectProtocol, + ); + authority.assertOwned(); + if (sharedAfterInheritance) + return { state: "pending", message: "task_artifact_owner_shared_with_sibling_transcript" }; + const published = await authority.publishReceipt({ + ...managedGcReceiptTarget(receipt), + state: "owner_retired", + taskArtifactOwnerDeletionEvidence: evidence, + artifactsRemoved: true, + taskArtifactOwnerRetirementOutcome: inheritedOutcome, + taskArtifactOwnerRetired: true, + }); + if (published.state !== "owner_retired") throw new Error("task_artifact_owner_inheritance_pending"); + return { state: "owner_retired" }; + } + const previous = receipt.taskArtifactOwnerRetirementOutcome; + const continuation = previous && previous.kind !== "completed" ? previous.continuation : undefined; + authority.assertOwned(); + const outcome = retireTaskArtifactOwner(authority.storageContext, evidence, continuation); + authority.assertOwned(); + if (outcome.kind === "completed") { + await authority.publishReceipt({ + ...managedGcReceiptTarget(receipt), + state: "owner_retired", + taskArtifactOwnerDeletionEvidence: evidence, + artifactsRemoved: true, + taskArtifactOwnerRetirementOutcome: outcome, + taskArtifactOwnerRetired: true, + }); + return { state: "owner_retired" }; + } + await authority.publishReceipt({ + ...managedGcReceiptTarget(receipt), + state: "owner_pending", + taskArtifactOwnerDeletionEvidence: evidence, + artifactsRemoved: true, + taskArtifactOwnerRetirementOutcome: outcome, + taskArtifactOwnerRetirementContinuation: outcome.continuation, + ...(outcome.kind === "payload_retired" + ? { taskArtifactOwnerPayloadRetired: true as const, taskArtifactOwnerNamespaceRetained: true as const } + : {}), + }); + return outcome.kind === "payload_retired" + ? { state: "payload_retired", message: "task_artifact_owner_namespace_cleanup_pending" } + : { + state: "pending", + message: outcome.kind === "uncertain" ? outcome.reason : "task_artifact_owner_namespace_cleanup_pending", + }; +} + +export async function persistManagedGcStorageOwnerDisposition( + authority: ManagedGcOwnerCleanupAuthority, + target: ManagedGcOwnerCleanupTarget, + deletion: Extract, +): Promise { + const evidence = parseTaskArtifactOwnerDeletionEvidence(deletion.taskArtifactOwnerDeletionEvidence); + if (!target.taskArtifactOwnerDeletionEvidence || !deepSame(evidence, target.taskArtifactOwnerDeletionEvidence)) + throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + const receipt = await publishManagedGcArtifactsRemoved(authority, target); + if (!receipt) throw new Error("task_artifact_owner_continuation_state_missing"); + const outcomeValue = deletion.taskArtifactOwnerRetirementOutcome; + if (!outcomeValue) return { state: "pending", message: deletion.error.message }; + const outcome = parseTaskArtifactOwnerRetirementOutcome(authority.storageContext, evidence, outcomeValue); + if ( + (deletion.taskArtifactOwnerRetired === true) !== (outcome.kind === "completed") || + (deletion.taskArtifactOwnerPayloadRetired === true) !== (outcome.kind === "payload_retired") || + (deletion.taskArtifactOwnerNamespaceRetained === true) !== (outcome.kind === "payload_retired") || + (outcome.kind !== "completed" && + !deepSame(deletion.taskArtifactOwnerRetirementContinuation, outcome.continuation)) + ) + throw new Error("task_artifact_owner_retirement_outcome_mismatch"); + if (outcome.kind === "completed") { + await authority.publishReceipt({ + ...managedGcReceiptTarget(receipt), + state: "owner_retired", + taskArtifactOwnerDeletionEvidence: evidence, + artifactsRemoved: true, + taskArtifactOwnerRetirementOutcome: outcome, + taskArtifactOwnerRetired: true, + }); + return { state: "owner_retired" }; + } + await authority.publishReceipt({ + ...managedGcReceiptTarget(receipt), + state: "owner_pending", + taskArtifactOwnerDeletionEvidence: evidence, + artifactsRemoved: true, + taskArtifactOwnerRetirementOutcome: outcome, + taskArtifactOwnerRetirementContinuation: outcome.continuation, + ...(outcome.kind === "payload_retired" + ? { taskArtifactOwnerPayloadRetired: true as const, taskArtifactOwnerNamespaceRetained: true as const } + : {}), + }); + return outcome.kind === "payload_retired" + ? { state: "payload_retired", message: "task_artifact_owner_namespace_cleanup_pending" } + : { + state: "pending", + message: outcome.kind === "uncertain" ? outcome.reason : "task_artifact_owner_namespace_cleanup_pending", + }; +} + +export async function managedGcOwnerProgressBeforeDelete( + authority: ManagedGcOwnerCleanupAuthority, + target: ManagedGcOwnerCleanupTarget, + tombstoneTargets: readonly ManagedGcOwnerCleanupTarget[], + artifactsRemoved: boolean, +): Promise { + const evidence = target.taskArtifactOwnerDeletionEvidence; + if (!evidence) return { state: "none" }; + const receipt = await authority.readReceipt(target.path); + if (!receipt || !deepSame(receipt.taskArtifactOwnerDeletionEvidence, evidence)) + throw new Error("task_artifact_owner_continuation_state_missing"); + if (receipt.state === "prepared" && !artifactsRemoved) { + authority.assertOwned(); + const shared = await hasSiblingTaskArtifactOwnerTranscript( + new FileSessionStorage(), + target.path, + evidence.locator, + authority.storageContext, + authority.inspectProtocol, + ); + authority.assertOwned(); + return shared + ? { state: "pending", message: "task_artifact_owner_shared_with_sibling_transcript" } + : { state: "none" }; + } + return retireManagedGcOwnerAfterArtifacts(authority, target, tombstoneTargets); +} + +export async function taskArtifactOwnerTranscriptResult( + authority: ManagedGcOwnerCleanupAuthority, + target: ManagedGcOwnerCleanupTarget, +): Promise<"none" | "retired" | "payload_retired"> { + if (path.dirname(target.path) !== authority.directoryPath) { + if (hasUnsupportedLegacyOwnerTarget(authority.directoryPath, target)) + throw new Error("task_artifact_owner_legacy_scope_unsupported"); + return "none"; + } + const receipt = await authority.readReceipt(target.path); + if (!receipt) { + if (target.taskArtifactOwnerDeletionEvidence) throw new Error("task_artifact_owner_continuation_state_missing"); + return "none"; + } + if (!deepSame(receipt.taskArtifactOwnerDeletionEvidence, target.taskArtifactOwnerDeletionEvidence)) + throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + if (receipt.state === "owner_retired") return "retired"; + return receipt.taskArtifactOwnerRetirementOutcome?.kind === "payload_retired" ? "payload_retired" : "none"; +} + +export interface ManagedGcOwnerTranscriptDeleteReceipt { + readonly taskArtifactOwnerTranscriptDeleted?: true; +} + +export async function persistManagedGcOwnerTranscriptDeletion(input: { + target: ManagedGcOwnerCleanupTarget; + fallback: T; + deletion: Extract; + readLatest: () => T | undefined; + nextReceipt: (latest: T) => T; + publishPending: (receipt: T) => Promise; +}): Promise { + if (!input.target.taskArtifactOwnerDeletionEvidence) return; + if (input.deletion.taskArtifactOwnerTranscriptDeleted !== true) + throw new Error("task_artifact_owner_transcript_deletion_unverified"); + const latest = input.readLatest() ?? input.fallback; + if (latest.taskArtifactOwnerTranscriptDeleted) return; + const next = input.nextReceipt(latest); + await input.publishPending({ ...next, taskArtifactOwnerTranscriptDeleted: true as const }); +} diff --git a/packages/coding-agent/test/managed-task-owner-cleanup-api.test.ts b/packages/coding-agent/test/managed-task-owner-cleanup-api.test.ts new file mode 100644 index 00000000000..2289e1679e3 --- /dev/null +++ b/packages/coding-agent/test/managed-task-owner-cleanup-api.test.ts @@ -0,0 +1,498 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as util from "node:util"; +import type { ManagedGcSessionRetirementReceipt } from "../src/session/internal/managed-gc-retirement-codec"; +import { + bindManagedGcSessionRetirementTarget, + discoverManagedGcSessionRetirementReceipts, + listManagedCandidates, + type ManagedCandidate, + type ManagedScope, + managedDirectoryIdentityForScope, + managedGcProtocolScopeInspectorForScope, + prepareManagedSessionScopeForWriteSync, + publishManagedGcSessionRetirementReceipt, + readManagedGcSessionRetirementReceipt, + resolveManagedScopeForWrite, + taskArtifactOwnerStorageContextForScope, +} from "../src/session/internal/managed-session-scope"; +import { acquireManagedLock, ManagedSessionDescendantStore } from "../src/session/internal/managed-session-storage"; +import { + type ManagedGcOwnerCleanupAuthority, + type ManagedGcOwnerCleanupTarget, + managedGcOwnerDeleteFields, + prepareManagedGcOwnerTarget, + retireManagedGcOwnerAfterArtifacts, +} from "../src/session/internal/managed-task-owner-cleanup"; +import { + captureTaskArtifactOwnerDeletionEvidence, + newSessionRootStore, +} from "../src/session/internal/task-artifact-owner-access"; +import { + OWNER_DIRECTORY, + OWNER_MANIFEST, + OWNER_SCHEMA_VERSION, + ownerIdForSession, + ownerRelativePath, + parseTaskArtifactOwnerLocator, + type TaskArtifactOwnerDeletionEvidence, + type TaskArtifactOwnerLocator, +} from "../src/session/task-artifact-owner-codec"; +import { verifyTaskArtifactOwnerPhysicalRetirement } from "../src/session/task-artifact-owner-retirement"; + +interface Fixture { + readonly root: string; + readonly agentDir: string; + readonly sessionsRoot: string; + readonly cwd: string; + readonly scope: ManagedScope; + readonly sessionId: string; + readonly transcriptPath: string; + readonly ownerPath: string; + readonly locator: TaskArtifactOwnerLocator; + readonly evidence: TaskArtifactOwnerDeletionEvidence; + readonly candidate: ManagedCandidate; +} + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +interface SiblingTarget { + readonly scope: ManagedScope; + readonly transcriptPath: string; + readonly candidate: ManagedCandidate; +} + +function makeFixture(): Fixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "managed-owner-cleanup-api-")); + roots.push(root); + const agentDir = path.join(root, "profile"); + const sessionsRoot = path.join(agentDir, "sessions"); + const cwd = path.join(root, "workspace"); + fs.mkdirSync(sessionsRoot, { recursive: true, mode: 0o700 }); + fs.mkdirSync(cwd, { mode: 0o700 }); + const resolved = resolveManagedScopeForWrite({ agentDir, sessionsRoot, cwd }); + if (resolved.kind !== "resolved") throw new Error(`fixture_scope_resolution_failed:${resolved.code}`); + const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope); + if (prepared.kind !== "resolved") throw new Error(`fixture_scope_prepare_failed:${prepared.code}`); + const scope = prepared.scope; + const storageContext = taskArtifactOwnerStorageContextForScope(scope); + const sessionId = `managed-owner-api-${crypto.randomUUID()}`; + const ownerId = ownerIdForSession(sessionId); + const rootStore = newSessionRootStore(storageContext); + let locator: TaskArtifactOwnerLocator | undefined; + try { + rootStore.ensureDirectory(OWNER_DIRECTORY); + const ownerDirectory = rootStore.ensureDirectory(ownerRelativePath(ownerId)); + locator = parseTaskArtifactOwnerLocator({ + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId, + directoryDev: ownerDirectory.dev.toString(), + directoryIno: ownerDirectory.ino.toString(), + }); + if (!locator) throw new Error("fixture_owner_locator_missing"); + const ownerStore = rootStore.deriveSubtree(ownerRelativePath(ownerId)); + try { + ownerStore.publishNoReplaceSync( + OWNER_MANIFEST, + Buffer.from(`${JSON.stringify({ ...locator, sessionId })}\n`, "utf8"), + ); + ownerStore.publishNoReplaceSync("payload.bin", Buffer.from("real-owner-payload", "utf8")); + } finally { + ownerStore.close(); + } + } finally { + rootStore.close(); + } + if (!locator) throw new Error("fixture_owner_locator_missing"); + const transcriptPath = path.join(scope.directoryPath, `${sessionId}.jsonl`); + const scopeIdentity = managedDirectoryIdentityForScope(scope); + const scopeStore = new ManagedSessionDescendantStore( + storageContext.rootAuthority, + scope.directoryPath, + undefined, + storageContext.securityPolicy, + storageContext.profileAgentDir, + { + canonicalPath: scope.directoryPath, + dev: BigInt.asUintN(64, scopeIdentity.dev), + ino: BigInt.asUintN(64, scopeIdentity.ino), + }, + ); + try { + scopeStore.publishNoReplaceSync( + path.basename(transcriptPath), + Buffer.from( + `${JSON.stringify({ type: "session", id: sessionId, cwd, version: 4, taskArtifactOwner: locator })}\n`, + ), + ); + } finally { + scopeStore.close(); + } + const evidence = captureTaskArtifactOwnerDeletionEvidence(storageContext, sessionId, locator); + if (!evidence) throw new Error("fixture_owner_evidence_missing"); + const listing = listManagedCandidates(scope); + if (listing.kind !== "complete") throw new Error(`fixture_candidate_listing_failed:${listing.kind}`); + const candidate = listing.owned.find(item => item.path === transcriptPath); + if (!candidate) throw new Error("fixture_candidate_missing"); + return { + root, + agentDir, + sessionsRoot, + cwd, + scope, + sessionId, + transcriptPath, + ownerPath: path.join(sessionsRoot, ownerRelativePath(ownerId)), + locator, + evidence, + candidate, + }; +} + +function makeSiblingTarget(fixture: Fixture): SiblingTarget { + const cwd = path.join(fixture.root, "workspace-sibling"); + fs.mkdirSync(cwd, { mode: 0o700 }); + const resolved = resolveManagedScopeForWrite({ + agentDir: fixture.agentDir, + sessionsRoot: fixture.sessionsRoot, + cwd, + }); + if (resolved.kind !== "resolved") throw new Error(`fixture_sibling_scope_resolution_failed:${resolved.code}`); + const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope); + if (prepared.kind !== "resolved") throw new Error(`fixture_sibling_scope_prepare_failed:${prepared.code}`); + const scope = prepared.scope; + const context = taskArtifactOwnerStorageContextForScope(scope); + const identity = managedDirectoryIdentityForScope(scope); + const transcriptPath = path.join(scope.directoryPath, `${fixture.sessionId}-sibling.jsonl`); + const store = new ManagedSessionDescendantStore( + context.rootAuthority, + scope.directoryPath, + undefined, + context.securityPolicy, + context.profileAgentDir, + { + canonicalPath: scope.directoryPath, + dev: BigInt.asUintN(64, identity.dev), + ino: BigInt.asUintN(64, identity.ino), + }, + ); + try { + store.publishNoReplaceSync( + path.basename(transcriptPath), + Buffer.from( + `${JSON.stringify({ type: "session", id: fixture.sessionId, cwd, version: 4, taskArtifactOwner: fixture.locator })}\n`, + ), + ); + } finally { + store.close(); + } + const listing = listManagedCandidates(scope); + if (listing.kind !== "complete") throw new Error(`fixture_sibling_listing_failed:${listing.kind}`); + const candidate = listing.owned.find(item => item.path === transcriptPath); + if (!candidate) throw new Error("fixture_sibling_candidate_missing"); + return { scope, transcriptPath, candidate }; +} + +function removeTranscript(scope: ManagedScope, transcriptPath: string): void { + const context = taskArtifactOwnerStorageContextForScope(scope); + const identity = managedDirectoryIdentityForScope(scope); + const store = new ManagedSessionDescendantStore( + context.rootAuthority, + scope.directoryPath, + undefined, + context.securityPolicy, + context.profileAgentDir, + { + canonicalPath: scope.directoryPath, + dev: BigInt.asUintN(64, identity.dev), + ino: BigInt.asUintN(64, identity.ino), + }, + ); + try { + store.removeIfExistsDescriptor(path.basename(transcriptPath)); + } finally { + store.close(); + } +} + +async function withAuthority( + fixture: Fixture, + operation: (authority: ManagedGcOwnerCleanupAuthority) => Promise, + scope: ManagedScope = fixture.scope, +): Promise { + const context = taskArtifactOwnerStorageContextForScope(scope); + // The outer scope fence must not hold the per-transcript journal publication lease. + const lockKey = crypto.createHash("sha256").update(scope.canonicalCwd).digest("hex"); + const lock = await acquireManagedLock( + path.join(scope.directoryPath, ".gjc-managed-session-internal", "locks"), + lockKey, + context.rootAuthority, + context.securityPolicy, + ); + const authority: ManagedGcOwnerCleanupAuthority = { + agentDir: scope.agentDir, + sessionsRoot: scope.sessionsRoot, + directoryPath: scope.directoryPath, + storageContext: context, + inspectProtocol: async inputs => { + lock.assertOwned(); + const snapshots = await managedGcProtocolScopeInspectorForScope(scope)(inputs); + lock.assertOwned(); + return snapshots; + }, + assertOwned: () => lock.assertOwned(), + bindTarget: transcriptPath => bindManagedGcSessionRetirementTarget(scope, transcriptPath), + readReceipt: async transcriptPath => { + lock.assertOwned(); + const receipt = await readManagedGcSessionRetirementReceipt(scope, transcriptPath); + lock.assertOwned(); + return receipt; + }, + publishReceipt: async receipt => { + lock.assertOwned(); + const published = await publishManagedGcSessionRetirementReceipt(scope, receipt); + lock.assertOwned(); + return published; + }, + findCompletedRetirement: async (evidence, exceptTranscriptPath) => { + lock.assertOwned(); + const records = await discoverManagedGcSessionRetirementReceipts({ + agentDir: scope.agentDir, + sessionsRoot: scope.sessionsRoot, + }); + lock.assertOwned(); + const found = records.find( + ({ scope: source, receipt }) => + source.agentDir === scope.agentDir && + source.sessionsRoot === scope.sessionsRoot && + source.directoryPath !== scope.directoryPath && + receipt.transcriptPath !== exceptTranscriptPath && + receipt.state === "owner_retired" && + receipt.taskArtifactOwnerRetirementOutcome?.kind === "completed" && + util.isDeepStrictEqual(receipt.taskArtifactOwnerDeletionEvidence, evidence), + ); + return found + ? { + scope: { + agentDir: found.scope.agentDir, + sessionsRoot: found.scope.sessionsRoot, + directoryPath: found.scope.directoryPath, + }, + receipt: found.receipt, + } + : undefined; + }, + }; + try { + return await operation(authority); + } finally { + await lock.release(); + } +} + +async function ownerReceipt(fixture: Fixture): Promise { + return readManagedGcSessionRetirementReceipt(fixture.scope, fixture.transcriptPath); +} + +describe("managed GC owner-cleanup API", () => { + it("captures and persists prepared authority from a real transcript and owner tree", async () => { + const fixture = makeFixture(); + expect(fs.existsSync(fixture.ownerPath)).toBe(true); + await withAuthority(fixture, async authority => { + const prepared = await prepareManagedGcOwnerTarget(authority, fixture.candidate); + expect(prepared.taskArtifactOwnerDeletionEvidence).toEqual(fixture.evidence); + const receipt = await authority.readReceipt(fixture.transcriptPath); + expect(receipt).toMatchObject({ + state: "prepared", + transcriptPath: fixture.transcriptPath, + sessionId: fixture.sessionId, + taskArtifactOwnerDeletionEvidence: fixture.evidence, + }); + }); + }); + + it("supplies only persisted receipt authority to native deletion", async () => { + const fixture = makeFixture(); + await withAuthority(fixture, async authority => { + const prepared = await prepareManagedGcOwnerTarget(authority, fixture.candidate); + const fields = await managedGcOwnerDeleteFields(authority, prepared); + expect(fields.taskArtifactOwnerStorageContext).toEqual(authority.storageContext); + expect(fields.taskArtifactOwnerDeletionEvidence).toEqual(fixture.evidence); + expect(fields.deferTaskArtifactOwnerRetirement).toBe(true); + expect(fields.taskArtifactOwnerRetired).toBeUndefined(); + }); + const receipt = await ownerReceipt(fixture); + expect(receipt?.state).toBe("prepared"); + }); + + it("inherits only a live-verified completed receipt from another authenticated v2 scope", async () => { + const fixture = makeFixture(); + const sibling = makeSiblingTarget(fixture); + let ownerTarget!: ManagedGcOwnerCleanupTarget; + let siblingTarget!: ManagedGcOwnerCleanupTarget; + await withAuthority(fixture, async authority => { + ownerTarget = await prepareManagedGcOwnerTarget(authority, fixture.candidate); + await authority.publishReceipt({ + ...authority.bindTarget(fixture.transcriptPath), + state: "artifacts_removed", + taskArtifactOwnerDeletionEvidence: fixture.evidence, + artifactsRemoved: true, + }); + }); + await withAuthority( + fixture, + async authority => { + siblingTarget = await prepareManagedGcOwnerTarget(authority, sibling.candidate); + await authority.publishReceipt({ + ...authority.bindTarget(sibling.transcriptPath), + state: "artifacts_removed", + taskArtifactOwnerDeletionEvidence: fixture.evidence, + artifactsRemoved: true, + }); + }, + sibling.scope, + ); + + removeTranscript(sibling.scope, sibling.transcriptPath); + let sourceReceipt: ManagedGcSessionRetirementReceipt | undefined; + let sourceProgress: Awaited> | undefined; + await withAuthority(fixture, async authority => { + sourceProgress = await retireManagedGcOwnerAfterArtifacts(authority, ownerTarget, [ownerTarget]); + sourceReceipt = await authority.readReceipt(fixture.transcriptPath); + expect(sourceReceipt).toBeDefined(); + }); + + if (sourceProgress?.state === "owner_retired") { + verifyTaskArtifactOwnerPhysicalRetirement( + taskArtifactOwnerStorageContextForScope(fixture.scope), + fixture.evidence, + sourceReceipt?.taskArtifactOwnerRetirementOutcome, + ); + removeTranscript(fixture.scope, fixture.transcriptPath); + await withAuthority( + fixture, + async authority => { + const found = await authority.findCompletedRetirement(fixture.evidence, sibling.transcriptPath); + expect(found?.receipt.state).toBe("owner_retired"); + expect(found?.receipt.taskArtifactOwnerDeletionEvidence).toEqual(fixture.evidence); + const progress = await retireManagedGcOwnerAfterArtifacts(authority, siblingTarget, [siblingTarget]); + expect(progress.state).toBe("owner_retired"); + const inherited = await authority.readReceipt(sibling.transcriptPath); + expect(inherited?.state).toBe("owner_retired"); + expect(inherited?.taskArtifactOwnerDeletionEvidence).toEqual(fixture.evidence); + expect(inherited?.taskArtifactOwnerRetirementOutcome).toEqual( + sourceReceipt?.taskArtifactOwnerRetirementOutcome, + ); + verifyTaskArtifactOwnerPhysicalRetirement( + authority.storageContext, + fixture.evidence, + inherited?.taskArtifactOwnerRetirementOutcome, + ); + }, + sibling.scope, + ); + } else { + expect(sourceReceipt?.state).toBe("owner_pending"); + removeTranscript(fixture.scope, fixture.transcriptPath); + await withAuthority( + fixture, + async authority => { + expect( + await authority.findCompletedRetirement(fixture.evidence, sibling.transcriptPath), + ).toBeUndefined(); + const progress = await retireManagedGcOwnerAfterArtifacts(authority, siblingTarget, [siblingTarget]); + const receipt = await authority.readReceipt(sibling.transcriptPath); + if (progress.state === "owner_retired") { + expect(receipt?.state).toBe("owner_retired"); + verifyTaskArtifactOwnerPhysicalRetirement( + authority.storageContext, + fixture.evidence, + receipt?.taskArtifactOwnerRetirementOutcome, + ); + } else { + expect(receipt?.state).toBe("owner_pending"); + expect(receipt?.taskArtifactOwnerRetirementOutcome?.kind).not.toBe("completed"); + } + }, + sibling.scope, + ); + } + }); + + it("rejects a completed receipt without native disposition and preserves the live owner", async () => { + const fixture = makeFixture(); + await withAuthority(fixture, async authority => { + const target = await prepareManagedGcOwnerTarget(authority, fixture.candidate); + await authority.publishReceipt({ + ...authority.bindTarget(fixture.transcriptPath), + state: "artifacts_removed", + taskArtifactOwnerDeletionEvidence: fixture.evidence, + artifactsRemoved: true, + }); + const sourceScope = { + agentDir: fixture.agentDir, + sessionsRoot: fixture.sessionsRoot, + directoryPath: path.join(fixture.sessionsRoot, `v2-${"a".repeat(52)}`), + }; + const forged: ManagedGcSessionRetirementReceipt = { + ...authority.bindTarget(fixture.transcriptPath), + transcriptPath: path.join(sourceScope.directoryPath, "retired.jsonl"), + state: "owner_retired", + taskArtifactOwnerDeletionEvidence: fixture.evidence, + artifactsRemoved: true, + taskArtifactOwnerRetirementOutcome: { kind: "completed", evidence: fixture.evidence }, + taskArtifactOwnerRetired: true, + }; + const untrustedLookup: ManagedGcOwnerCleanupAuthority = { + ...authority, + findCompletedRetirement: async () => ({ scope: sourceScope, receipt: forged }), + }; + await expect(retireManagedGcOwnerAfterArtifacts(untrustedLookup, target, [target])).rejects.toThrow( + "task_artifact_owner_retirement_outcome_invalid", + ); + expect(fs.existsSync(fixture.ownerPath)).toBe(true); + expect((await authority.readReceipt(fixture.transcriptPath))?.state).toBe("artifacts_removed"); + }); + }); + + it("runs native owner retirement and records its actual terminal or pending proof", async () => { + const fixture = makeFixture(); + await withAuthority(fixture, async authority => { + const target = await prepareManagedGcOwnerTarget(authority, fixture.candidate); + const progress = await retireManagedGcOwnerAfterArtifacts(authority, target, [target]); + const receipt = await authority.readReceipt(fixture.transcriptPath); + expect(receipt).toBeDefined(); + if (progress.state === "owner_retired") { + expect(receipt?.state).toBe("owner_retired"); + expect(receipt?.taskArtifactOwnerRetirementOutcome?.kind).toBe("completed"); + verifyTaskArtifactOwnerPhysicalRetirement( + authority.storageContext, + fixture.evidence, + receipt?.taskArtifactOwnerRetirementOutcome, + ); + expect(fs.existsSync(fixture.ownerPath)).toBe(false); + } else { + expect(["pending", "payload_retired"]).toContain(progress.state); + expect(receipt?.state).toBe("owner_pending"); + const outcome = receipt?.taskArtifactOwnerRetirementOutcome; + expect(outcome).toBeDefined(); + expect(outcome?.kind).not.toBe("completed"); + expect(outcome?.evidence).toEqual(fixture.evidence); + if (outcome && outcome.kind !== "completed") + expect(receipt?.taskArtifactOwnerRetirementContinuation).toEqual(outcome.continuation); + if (receipt?.taskArtifactOwnerRetirementOutcome?.kind === "payload_retired") { + expect(receipt.taskArtifactOwnerRetirementOutcome.nativeOutcome.ok).toBe(false); + expect(receipt.taskArtifactOwnerRetirementOutcome.nativeOutcome.code).toBe("cleanup_pending"); + expect(receipt.taskArtifactOwnerRetirementOutcome.nativeOutcome.payloadDurable).toBe(true); + } + } + }); + }); +});