From 6677b6ca965005b32d4c57cba673696869d8fb16 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 19:34:52 +0900 Subject: [PATCH] feat(sdk): preserve fenced logical owner retirement through deletion Saved-session deletion and replay preserve original authority and actual native outcomes before transcript completion. Pass the live scope inspector without serializing it. Deferring owner retirement cannot bypass sibling/protocol preflight before artifact effects; the real SDK alias regression exposed and now guards that shared storage boundary. Lore-id: 610bfd2a Constraint: producer/admission stays off until all consumers install Constraint: native payload retirement does not imply physical reclamation Tested: real SDK unknown-protocol alias preserves artifacts owner transcript and unrelated session Tested: deferred storage refuses without actual protocol inspector Tested: six SDK managed journal storage suites and full coding-agent check exit0 Not-tested: fresh cross-platform runtime and final cumulative gates Confidence: high Scope-risk: bounded Reversibility: revert --- .../changelog.d/sdk-task-owner-deletion.md | 3 + .../coding-agent/src/sdk/broker/broker.ts | 41 ++ .../coding-agent/src/sdk/broker/lifecycle.ts | 684 +++++++++++++++-- .../sdk-task-artifact-owner-deletion.test.ts | 686 ++++++++++++++++++ .../test/task-artifact-owner-storage.test.ts | 18 +- 5 files changed, 1361 insertions(+), 71 deletions(-) create mode 100644 packages/coding-agent/changelog.d/sdk-task-owner-deletion.md create mode 100644 packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts diff --git a/packages/coding-agent/changelog.d/sdk-task-owner-deletion.md b/packages/coding-agent/changelog.d/sdk-task-owner-deletion.md new file mode 100644 index 00000000000..c02f5a9e24e --- /dev/null +++ b/packages/coding-agent/changelog.d/sdk-task-owner-deletion.md @@ -0,0 +1,3 @@ +### Fixed + +- SDK saved-session deletion now durably prepares managed task-artifact owner evidence before native effects, validates owner cleanup on replay against the current managed scope, and keeps scrubbed-but-retained owner namespaces pending without treating DTO completion as physical proof. diff --git a/packages/coding-agent/src/sdk/broker/broker.ts b/packages/coding-agent/src/sdk/broker/broker.ts index 10bbf196536..ab076fc6d5d 100644 --- a/packages/coding-agent/src/sdk/broker/broker.ts +++ b/packages/coding-agent/src/sdk/broker/broker.ts @@ -9,6 +9,10 @@ import packageJson from "../../../package.json" with { type: "json" }; import type { ModelProfileErrorDetails } from "../../config/model-profile-contract"; import { planLaunchWorktree } from "../../gjc-runtime/launch-worktree"; import { readExistingStateForMutation, withWorkflowStateLock } from "../../gjc-runtime/state-writer"; +import type { + TaskArtifactOwnerDeletionEvidence, + TaskArtifactOwnerRetirementContinuation, +} from "../../session/task-artifact-owner-codec"; import { SdkClient, SdkClientError } from "../client"; import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, @@ -127,6 +131,7 @@ import { type SpawnSubstrateProvider, } from "./spawn-authority"; import { createSpawnSubstrateProvider } from "./spawn-substrate"; +import type { BrokerTaskArtifactOwnerRetirementDisposition } from "./task-artifact-owner-validation"; import { BrokerTransport } from "./transport"; export interface BrokerSettings { @@ -293,6 +298,19 @@ export type BrokerCleanupEvidence = { retainedTranscriptSuccessorPath?: string; retainedTranscriptPlaceholderPath?: string; retainedTranscriptUnknownPath?: string; + /** Immutable task-artifact owner authority captured before the first deletion effect. */ + taskArtifactOwnerDeletionEvidence?: TaskArtifactOwnerDeletionEvidence; + /** Latest exact native remnant; it never replaces the original deletion evidence. */ + taskArtifactOwnerRetirementContinuation?: TaskArtifactOwnerRetirementContinuation; + /** Strict owner-only wire disposition, decoded only with its separately stored evidence. */ + taskArtifactOwnerRetirementOutcome?: BrokerTaskArtifactOwnerRetirementDisposition; + taskArtifactOwnerPayloadRetired?: true; + taskArtifactOwnerNamespaceRetained?: true; + taskArtifactOwnerRetired?: true; + /** Durable fact that transcript retirement completed while the owner namespace remains. */ + taskArtifactOwnerTranscriptDeleted?: true; + /** Stable artifacts-phase owner refusal diagnostic. */ + taskArtifactOwnerCleanupError?: string; /** Durable proof that artifact cleanup completed before transcript mutation. */ artifactsRemoved?: boolean; artifactsAbsentAtAuthorization?: true; @@ -756,6 +774,17 @@ function normalizeAliasedString( return { value: values[0] }; } +const BROKER_TASK_ARTIFACT_OWNER_CLEANUP_FIELDS = [ + "taskArtifactOwnerDeletionEvidence", + "taskArtifactOwnerRetirementContinuation", + "taskArtifactOwnerRetirementOutcome", + "taskArtifactOwnerPayloadRetired", + "taskArtifactOwnerNamespaceRetained", + "taskArtifactOwnerRetired", + "taskArtifactOwnerTranscriptDeleted", + "taskArtifactOwnerCleanupError", +] as const; + export function normalizeBrokerInput(operation: string, input: Record): InputNormalization { const normalized: Record = { ...input }; const session = normalizeAliasedString(input, "sessionId", ["id"]); @@ -834,6 +863,18 @@ export function normalizeBrokerInput(operation: string, input: Record + typeof value === "object" && + value !== null && + !Array.isArray(value) && + BROKER_TASK_ARTIFACT_OWNER_CLEANUP_FIELDS.some(key => Object.hasOwn(value, key)); + if (hasOwnerField(input) || hasOwnerField(target) || hasOwnerField(input.cleanup)) + return error( + "invalid_input", + "Task-artifact owner cleanup state is broker-managed and cannot be supplied by clients.", + ); + } if (target) { const normalizedTarget = { ...target }; delete normalizedTarget.path; diff --git a/packages/coding-agent/src/sdk/broker/lifecycle.ts b/packages/coding-agent/src/sdk/broker/lifecycle.ts index 462f9fad67c..d8e5bb938ca 100644 --- a/packages/coding-agent/src/sdk/broker/lifecycle.ts +++ b/packages/coding-agent/src/sdk/broker/lifecycle.ts @@ -43,7 +43,19 @@ import { GJC_COORDINATOR_SIDECAR_SIGNATURE_REQUIRED_ENV, GJC_COORDINATOR_SIDECAR_SIGNING_KEY_ENV, } from "../../gjc-runtime/session-state-sidecar"; +import { + type ManagedGcProtocolScopeInspector, + type ManagedScope, + managedGcProtocolScopeInspectorForScope, + resolveManagedGcScopeForRead, + taskArtifactOwnerStorageContextForScope, +} from "../../session/internal/managed-session-scope"; import { validateManagedArtifactTree } from "../../session/internal/managed-session-storage"; +import { captureTaskArtifactOwnerDeletionEvidence } from "../../session/internal/task-artifact-owner-access"; +import { + hasSiblingTaskArtifactOwnerTranscript, + taskArtifactOwnerLocatorFromTranscriptBytes, +} from "../../session/internal/task-artifact-owner-transcript"; import { FileSessionStorage, SessionDeleteVerificationError, @@ -52,7 +64,17 @@ import { type VerifiedSessionDeleteResult, type VerifiedSessionDeleteTarget, } from "../../session/session-storage"; +import { parseFirstJsonlLine } from "../../session/session-transcript-header"; import type { SessionWorkLease } from "../../session/session-work-lease"; +import type { + TaskArtifactOwnerDeletionEvidence, + TaskArtifactOwnerRetirementOutcome, + TaskArtifactOwnerStorageContext, +} from "../../session/task-artifact-owner-codec"; +import { + retireTaskArtifactOwner, + verifyTaskArtifactOwnerPhysicalRetirement, +} from "../../session/task-artifact-owner-retirement"; import type { SessionLifecycleMcpServer } from "../acp/mcp"; import { SdkClient, SdkClientError } from "../client/client"; import { BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD } from "../host/control/runtime-gate"; @@ -106,6 +128,12 @@ import { readPreparationTimeouts, startupQueueWaitMs, } from "./startup-budget"; +import { + type BrokerTaskArtifactOwnerCleanupValidation, + type BrokerTaskArtifactOwnerRetirementDisposition, + decodeBrokerTaskArtifactOwnerCleanupFields, + serializeBrokerTaskArtifactOwnerRetirementDisposition, +} from "./task-artifact-owner-validation"; import { worktreeOccupant } from "./worktree-occupancy"; export { @@ -961,6 +989,130 @@ function lifecycleLaunchKnownSecrets(launch: SessionLaunch): string[] { } type CleanupEvidence = BrokerCleanupEvidence; + +function brokerTaskArtifactOwnerCleanupFields(cleanup: CleanupEvidence): unknown { + const fields: Record = {}; + for (const [key, value] of Object.entries(cleanup)) { + if (key.startsWith("taskArtifactOwner")) fields[key] = value; + } + return fields; +} + +function managedOwnerScopeFromInventory(scope: ManagedSessionScope): ManagedScope { + const resolved = resolveManagedGcScopeForRead({ + cwd: scope.legacyLexicalCwd, + agentDir: scope.agentDir, + sessionsRoot: scope.sessionsRoot, + }); + if ( + resolved.kind !== "resolved" || + resolved.scope.apiVersion !== scope.apiVersion || + resolved.scope.layoutVersion !== scope.layoutVersion || + resolved.scope.identityVersion !== scope.identityVersion || + resolved.scope.agentDir !== scope.agentDir || + resolved.scope.sessionsRoot !== scope.sessionsRoot || + resolved.scope.canonicalCwd !== scope.canonicalCwd || + resolved.scope.legacyLexicalCwd !== scope.legacyLexicalCwd || + resolved.scope.directoryName !== scope.directoryName || + resolved.scope.directoryPath !== scope.directoryPath + ) + throw new Error("managed_task_artifact_owner_scope_changed"); + return resolved.scope; +} + +function taskArtifactOwnerTranscriptMatches( + storage: FileSessionStorage, + target: VerifiedSessionDeleteTarget, + evidence: TaskArtifactOwnerDeletionEvidence, +): boolean { + const transcriptPath = target.detachedTranscriptPath ?? target.transcriptPath; + try { + const parent = fsSync.lstatSync(path.dirname(transcriptPath), { bigint: true }); + if ( + !parent.isDirectory() || + parent.isSymbolicLink() || + parent.dev !== target.transcriptParentIdentity?.dev || + parent.ino !== target.transcriptParentIdentity?.ino + ) + return false; + const snapshot = storage.readSnapshotSync(transcriptPath); + const digest = createHash("sha256").update(snapshot.bytes).digest("hex"); + if ( + !snapshot.stat.isFile || + snapshot.stat.dev !== target.transcriptIdentity.dev || + snapshot.stat.ino !== target.transcriptIdentity.ino || + snapshot.stat.nlink !== target.transcriptIdentity.nlink || + snapshot.stat.size !== target.transcriptIdentity.size || + snapshot.stat.mtimeNs !== target.transcriptIdentity.mtimeNs || + digest !== target.transcriptIdentity.sha256 + ) + return false; + const header = parseFirstJsonlLine(snapshot.bytes); + if ( + !header || + typeof header.cwd !== "string" || + canonicalExistingPath(header.cwd) !== canonicalExistingPath(target.cwd) + ) + return false; + const locator = taskArtifactOwnerLocatorFromTranscriptBytes(snapshot.bytes, target.sessionId); + return ( + locator?.schemaVersion === evidence.locator.schemaVersion && + locator.ownerId === evidence.locator.ownerId && + locator.directoryDev === evidence.locator.directoryDev && + locator.directoryIno === evidence.locator.directoryIno + ); + } catch { + return false; + } +} + +function taskArtifactOwnerFailureDiagnostic(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return ( + message.startsWith("task_artifact_owner_") ? message : `task_artifact_owner_cleanup_failed:${message}` + ).slice(0, 4096); +} + +function taskArtifactOwnerRetirementDisposition( + outcome: TaskArtifactOwnerRetirementOutcome, +): BrokerTaskArtifactOwnerRetirementDisposition { + return serializeBrokerTaskArtifactOwnerRetirementDisposition(outcome); +} + +function captureTaskArtifactOwnerEvidence( + storage: FileSessionStorage, + target: VerifiedSessionDeleteTarget, +): { evidence?: TaskArtifactOwnerDeletionEvidence; error?: string } { + const transcriptPath = target.detachedTranscriptPath ?? target.transcriptPath; + let snapshot: SessionStorageSnapshot; + try { + snapshot = storage.readSnapshotSync(transcriptPath); + } catch { + return { error: "task_artifact_owner_transcript_unavailable" }; + } + const digest = createHash("sha256").update(snapshot.bytes).digest("hex"); + if ( + !snapshot.stat.isFile || + snapshot.stat.dev !== target.transcriptIdentity.dev || + snapshot.stat.ino !== target.transcriptIdentity.ino || + snapshot.stat.nlink !== target.transcriptIdentity.nlink || + snapshot.stat.size !== target.transcriptIdentity.size || + snapshot.stat.mtimeNs !== target.transcriptIdentity.mtimeNs || + digest !== target.transcriptIdentity.sha256 + ) + return { error: "task_artifact_owner_transcript_identity_mismatch" }; + try { + const locator = taskArtifactOwnerLocatorFromTranscriptBytes(snapshot.bytes, target.sessionId); + if (!locator) return {}; + const context = target.taskArtifactOwnerStorageContext; + if (!context) return { error: "task_artifact_owner_context_missing" }; + const evidence = captureTaskArtifactOwnerDeletionEvidence(context, target.sessionId, locator); + return evidence ? { evidence } : { error: "task_artifact_owner_evidence_missing" }; + } catch (error) { + return { error: taskArtifactOwnerFailureDiagnostic(error) }; + } +} + type CleanupIdentity = { dev: bigint; ino: bigint; @@ -5965,6 +6117,8 @@ type ValidatedDelete = { target: VerifiedSessionDeleteTarget; metadataRoot: string; transcriptParentIdentity: { dev: string; ino: string }; + taskArtifactOwnerCaptureError?: string; + inspectProtocol?: ManagedGcProtocolScopeInspector; }; function cleanupIdentity( identity: BrokerCleanupEvidence["transcriptIdentity"], @@ -6038,8 +6192,12 @@ function replayDeleteTarget(cleanup: CleanupEvidence): ValidatedDelete | BrokerR (artifactsIdentity.dev !== artifactTreeIdentity.dev || artifactsIdentity.ino !== artifactTreeIdentity.ino) ) return fail("terminal_uncertain", "Artifact cleanup tree does not match its ledger-bound root identity."); - if (cleanup.phase === "artifacts" && cleanup.artifactsRemoved === true) - return fail("terminal_uncertain", "Artifacts-phase cleanup receipt falsely claims artifact completion."); + if ( + cleanup.phase === "artifacts" && + cleanup.artifactsRemoved === true && + cleanup.taskArtifactOwnerDeletionEvidence === undefined + ) + return fail("terminal_uncertain", "Artifacts-phase cleanup receipt lacks immutable owner evidence."); if ( cleanup.artifactsRemoved === true && cleanup.artifactTree && @@ -6243,7 +6401,60 @@ async function validateDeletePath( canonicalExistingPath(replay.metadataRoot) !== canonicalRequestedRoot ) return fail("invalid_input", "Cleanup receipt does not match the requested saved-session locator."); - return replay; + const inventory = await managedCandidates(broker, cwd, "Saved"); + if ("ok" in inventory) return inventory; + const transcriptRelative = path.relative(inventory.scope.sessionsRoot, replay.target.transcriptPath); + if ( + path.resolve(replay.target.sessionsRoot) !== inventory.scope.sessionsRoot || + transcriptRelative === "" || + transcriptRelative === ".." || + transcriptRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(transcriptRelative) + ) + return fail("terminal_uncertain", "Cleanup receipt does not match the current managed session authority."); + let ownerContext: TaskArtifactOwnerStorageContext; + let ownerScope: ManagedScope; + try { + ownerScope = managedOwnerScopeFromInventory(inventory.scope); + ownerContext = taskArtifactOwnerStorageContextForScope(ownerScope); + } catch { + return fail( + "terminal_uncertain", + "Managed task-artifact-owner authority could not be restored for cleanup replay.", + ); + } + let owner: BrokerTaskArtifactOwnerCleanupValidation; + try { + owner = decodeBrokerTaskArtifactOwnerCleanupFields( + brokerTaskArtifactOwnerCleanupFields(cleanup), + ownerContext, + id, + cleanup.phase, + cleanup.artifactsRemoved, + ); + } catch { + return fail("terminal_uncertain", "Cleanup receipt contains invalid task-artifact owner state."); + } + replay.target.sessionsRoot = inventory.scope.sessionsRoot; + if (owner.deletionEvidence) { + replay.target.taskArtifactOwnerStorageContext = ownerContext; + replay.target.taskArtifactOwnerDeletionEvidence = owner.deletionEvidence; + replay.target.deferTaskArtifactOwnerRetirement = true; + if (owner.retirementContinuation) + replay.target.taskArtifactOwnerRetirementContinuation = owner.retirementContinuation; + if (owner.retirementOutcome) replay.target.taskArtifactOwnerRetirementOutcome = owner.retirementOutcome; + if (owner.retired) replay.target.taskArtifactOwnerRetired = true; + if (owner.payloadRetired) replay.target.taskArtifactOwnerPayloadRetired = true; + if (owner.namespaceRetained) replay.target.taskArtifactOwnerNamespaceRetained = true; + if (owner.transcriptDeleted) replay.target.taskArtifactOwnerTranscriptDeleted = true; + } + return { + ...replay, + inspectProtocol: managedGcProtocolScopeInspectorForScope(ownerScope), + ...(owner.cleanupDiagnostic && !owner.deletionEvidence + ? { taskArtifactOwnerCaptureError: owner.cleanupDiagnostic } + : {}), + }; } const inventory = await managedCandidates(broker, cwd, "Saved"); if ("ok" in inventory) return inventory; @@ -6255,6 +6466,14 @@ async function validateDeletePath( const match = matches[0]!; if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy") return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace."); + let ownerContext: TaskArtifactOwnerStorageContext; + let ownerScope: ManagedScope; + try { + ownerScope = managedOwnerScopeFromInventory(inventory.scope); + ownerContext = taskArtifactOwnerStorageContextForScope(ownerScope); + } catch { + return fail("invalid_input", "Managed task-artifact-owner authority could not be established for deletion."); + } const storage = new FileSessionStorage(); let snapshot: SessionStorageSnapshot; @@ -6284,28 +6503,37 @@ async function validateDeletePath( } catch { return fail("invalid_input", "session.delete transcript parent changed during authorization."); } + const target: VerifiedSessionDeleteTarget = { + sessionsRoot: canonicalExistingPath(inventory.scope.sessionsRoot), + transcriptPath: candidatePath, + sessionId: id, + cwd, + transcriptIdentity: { + dev: snapshot.stat.dev, + ino: snapshot.stat.ino, + nlink: snapshot.stat.nlink, + size: snapshot.stat.size, + mtimeNs: snapshot.stat.mtimeNs, + sha256: digest, + }, + transcriptParentIdentity: { dev: transcriptParentStat.dev, ino: transcriptParentStat.ino }, + taskArtifactOwnerStorageContext: ownerContext, + }; + const capturedOwner = captureTaskArtifactOwnerEvidence(storage, target); + if (capturedOwner.evidence) { + target.taskArtifactOwnerDeletionEvidence = capturedOwner.evidence; + target.deferTaskArtifactOwnerRetirement = true; + } return { storage, - target: { - sessionsRoot: canonicalExistingPath(inventory.scope.sessionsRoot), - transcriptPath: candidatePath, - sessionId: id, - cwd, - transcriptIdentity: { - dev: snapshot.stat.dev, - ino: snapshot.stat.ino, - nlink: snapshot.stat.nlink, - size: snapshot.stat.size, - mtimeNs: snapshot.stat.mtimeNs, - sha256: digest, - }, - transcriptParentIdentity: { dev: transcriptParentStat.dev, ino: transcriptParentStat.ino }, - }, + target, metadataRoot: canonicalRequestedRoot, + inspectProtocol: managedGcProtocolScopeInspectorForScope(ownerScope), transcriptParentIdentity: { dev: transcriptParentStat.dev.toString(), ino: transcriptParentStat.ino.toString(), }, + ...(capturedOwner.error ? { taskArtifactOwnerCaptureError: capturedOwner.error } : {}), }; } type CloseAuthority = { endpointGeneration: number; endpointIncarnation: string }; @@ -7483,15 +7711,45 @@ async function executeLifecycleResponse( const transcriptParentIdentity = cleanup?.transcriptParentIdentity ?? validated.transcriptParentIdentity; const durableArtifactsPlan = cleanup?.artifactTree?.plannedPath ?? cleanup?.plannedArtifactsPath ?? cleanupTarget.plannedArtifactsPath; + let ownerRetirementOutcome = cleanupTarget.taskArtifactOwnerRetirementOutcome + ? taskArtifactOwnerRetirementDisposition(cleanupTarget.taskArtifactOwnerRetirementOutcome) + : undefined; + let ownerTranscriptDeleted = cleanupTarget.taskArtifactOwnerTranscriptDeleted; + let freshPayloadRetirementForThisAttempt = false; + const ownerNamespacePending = + cleanupTarget.taskArtifactOwnerPayloadRetired === true && + cleanupTarget.taskArtifactOwnerNamespaceRetained === true && + cleanupTarget.taskArtifactOwnerRetired !== true; const preauthorizedCleanup: CleanupEvidence = { cleanupReceiptVersion: 1, - phase: cleanupTarget.artifactsRemoved ? "transcript" : "artifacts", + phase: + cleanupTarget.artifactsRemoved && + !ownerNamespacePending && + (cleanupTarget.taskArtifactOwnerRetired === true || !cleanupTarget.taskArtifactOwnerDeletionEvidence) + ? "transcript" + : "artifacts", sessionId: cleanupTarget.sessionId, sessionsRoot: cleanupTarget.sessionsRoot, transcriptPath: cleanupTarget.transcriptPath, cwd: cleanupTarget.cwd, ...(cleanupTarget.artifactsRemoved ? { artifactsRemoved: true } : {}), ...(cleanupTarget.artifactsAbsentAtAuthorization ? { artifactsAbsentAtAuthorization: true as const } : {}), + ...(cleanupTarget.taskArtifactOwnerDeletionEvidence + ? { taskArtifactOwnerDeletionEvidence: cleanupTarget.taskArtifactOwnerDeletionEvidence } + : {}), + ...(cleanupTarget.taskArtifactOwnerRetirementContinuation + ? { taskArtifactOwnerRetirementContinuation: cleanupTarget.taskArtifactOwnerRetirementContinuation } + : {}), + ...(ownerRetirementOutcome ? { taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome } : {}), + ...(cleanupTarget.taskArtifactOwnerPayloadRetired ? { taskArtifactOwnerPayloadRetired: true as const } : {}), + ...(cleanupTarget.taskArtifactOwnerNamespaceRetained + ? { taskArtifactOwnerNamespaceRetained: true as const } + : {}), + ...(cleanupTarget.taskArtifactOwnerRetired ? { taskArtifactOwnerRetired: true as const } : {}), + ...(ownerTranscriptDeleted ? { taskArtifactOwnerTranscriptDeleted: true as const } : {}), + ...(validated.taskArtifactOwnerCaptureError + ? { taskArtifactOwnerCleanupError: validated.taskArtifactOwnerCaptureError } + : {}), metadataRoot: validated.metadataRoot, transcriptIdentity: serializeCleanupIdentity(cleanupTarget.transcriptIdentity), transcriptParentIdentity, @@ -7591,6 +7849,143 @@ async function executeLifecycleResponse( }); return pending; }; + const cleanupWithFreshOwnerPayload = (receipt: CleanupEvidence): CleanupEvidence => ({ + ...receipt, + ...(cleanupTarget.taskArtifactOwnerDeletionEvidence + ? { taskArtifactOwnerDeletionEvidence: cleanupTarget.taskArtifactOwnerDeletionEvidence } + : {}), + taskArtifactOwnerRetirementContinuation: cleanupTarget.taskArtifactOwnerRetirementContinuation, + taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome, + taskArtifactOwnerPayloadRetired: cleanupTarget.taskArtifactOwnerPayloadRetired, + taskArtifactOwnerNamespaceRetained: cleanupTarget.taskArtifactOwnerNamespaceRetained, + taskArtifactOwnerRetired: cleanupTarget.taskArtifactOwnerRetired, + taskArtifactOwnerTranscriptDeleted: ownerTranscriptDeleted, + }); + const publishTaskArtifactOwnerPending = async ( + error: unknown, + receipt: CleanupEvidence = preauthorizedCleanup, + ): Promise => { + const diagnostic = taskArtifactOwnerFailureDiagnostic(error); + const evidence = cleanupTarget.taskArtifactOwnerDeletionEvidence; + const ownerReceipt: CleanupEvidence = { + ...receipt, + phase: "artifacts", + artifactsRemoved: receipt.artifactsRemoved === true && evidence ? true : undefined, + artifactsAbsentAtAuthorization: + receipt.artifactsRemoved === true ? undefined : receipt.artifactsAbsentAtAuthorization, + ...(evidence ? { taskArtifactOwnerDeletionEvidence: evidence } : {}), + taskArtifactOwnerRetirementContinuation: cleanupTarget.taskArtifactOwnerRetirementContinuation, + taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome, + taskArtifactOwnerPayloadRetired: cleanupTarget.taskArtifactOwnerPayloadRetired, + taskArtifactOwnerNamespaceRetained: cleanupTarget.taskArtifactOwnerNamespaceRetained, + taskArtifactOwnerRetired: cleanupTarget.taskArtifactOwnerRetired, + taskArtifactOwnerTranscriptDeleted: ownerTranscriptDeleted, + taskArtifactOwnerCleanupError: diagnostic, + }; + const pending = fail( + "cleanup_pending", + `Saved session cleanup is pending in artifacts: ${diagnostic}`, + ownerReceipt, + ); + await broker.ledger.transition(identity, "effect_started", { + intendedSessionId: id, + response: pending, + }); + return pending; + }; + const persistFreshTaskArtifactOwnerRetirement = async ( + receipt: CleanupEvidence, + ): Promise => { + const evidence = cleanupTarget.taskArtifactOwnerDeletionEvidence; + if (!evidence) return undefined; + const ownerContext = cleanupTarget.taskArtifactOwnerStorageContext; + if (!ownerContext) + return await publishTaskArtifactOwnerPending("task_artifact_owner_context_missing", receipt); + if ( + ownerTranscriptDeleted === true + ? !logicalTranscriptRetirementMatches() + : !taskArtifactOwnerTranscriptMatches(validated.storage, cleanupTarget, evidence) + ) + return await publishTaskArtifactOwnerPending("task_artifact_owner_transcript_identity_mismatch", receipt); + let outcome: TaskArtifactOwnerRetirementOutcome; + try { + if ( + await hasSiblingTaskArtifactOwnerTranscript( + validated.storage, + cleanupTarget.transcriptPath, + evidence.locator, + ownerContext, + validated.inspectProtocol, + ) + ) + return await publishTaskArtifactOwnerPending( + "task_artifact_owner_shared_with_sibling_transcript", + receipt, + ); + outcome = retireTaskArtifactOwner( + ownerContext, + evidence, + cleanupTarget.taskArtifactOwnerRetirementContinuation, + ); + } catch (error) { + freshPayloadRetirementForThisAttempt = false; + return await publishTaskArtifactOwnerPending(error, receipt); + } + freshPayloadRetirementForThisAttempt = outcome.kind === "payload_retired"; + cleanupTarget.taskArtifactOwnerRetirementOutcome = outcome; + ownerRetirementOutcome = taskArtifactOwnerRetirementDisposition(outcome); + cleanupTarget.taskArtifactOwnerRetired = outcome.kind === "completed" ? true : undefined; + cleanupTarget.taskArtifactOwnerRetirementContinuation = + outcome.kind === "completed" ? undefined : outcome.continuation; + cleanupTarget.taskArtifactOwnerPayloadRetired = outcome.kind === "payload_retired" ? true : undefined; + cleanupTarget.taskArtifactOwnerNamespaceRetained = outcome.kind === "payload_retired" ? true : undefined; + if (outcome.kind === "completed") ownerTranscriptDeleted = undefined; + const ownerError = + outcome.kind === "uncertain" + ? taskArtifactOwnerFailureDiagnostic(outcome.reason) + : outcome.kind === "cleanup_pending" + ? taskArtifactOwnerFailureDiagnostic( + outcome.nativeOutcome?.code ?? "task_artifact_owner_cleanup_pending", + ) + : undefined; + const ownerReceipt: CleanupEvidence = { + ...receipt, + phase: "artifacts", + artifactsRemoved: true, + artifactsAbsentAtAuthorization: undefined, + taskArtifactOwnerDeletionEvidence: evidence, + taskArtifactOwnerRetired: outcome.kind === "completed" ? true : undefined, + taskArtifactOwnerRetirementContinuation: outcome.kind === "completed" ? undefined : outcome.continuation, + taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome, + taskArtifactOwnerPayloadRetired: outcome.kind === "payload_retired" ? true : undefined, + taskArtifactOwnerNamespaceRetained: outcome.kind === "payload_retired" ? true : undefined, + taskArtifactOwnerTranscriptDeleted: + outcome.kind !== "completed" && ownerTranscriptDeleted === true ? true : undefined, + taskArtifactOwnerCleanupError: ownerError, + }; + const stateResponse = fail( + "cleanup_pending", + outcome.kind === "payload_retired" + ? "Task-artifact payload is retired; its native namespace remains pending in artifacts." + : outcome.kind === "completed" + ? "Task-artifact owner retirement is durably complete; transcript cleanup is preauthorized." + : `Task-artifact owner retirement remains ${outcome.kind} in artifacts.`, + ownerReceipt, + ); + await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, response: stateResponse }); + return outcome.kind === "completed" || outcome.kind === "payload_retired" ? undefined : stateResponse; + }; + const storageOwnerFields = (): Partial => { + const evidence = cleanupTarget.taskArtifactOwnerDeletionEvidence; + if (!evidence) return {}; + if (!cleanupTarget.taskArtifactOwnerStorageContext) throw new Error("task_artifact_owner_context_missing"); + return { + deferTaskArtifactOwnerRetirement: true, + ...(cleanupTarget.taskArtifactOwnerRetirementOutcome + ? { taskArtifactOwnerRetirementOutcome: cleanupTarget.taskArtifactOwnerRetirementOutcome } + : {}), + }; + }; const canonicalArtifactsPath = cleanupTarget.transcriptPath.slice(0, -6); const transcriptCleanupAuthorityIsAbsent = (): boolean => [ @@ -7646,6 +8041,11 @@ async function executeLifecycleResponse( return false; } }; + const logicalTranscriptRetirementMatches = (): boolean => + transcriptParentMatchesPersistedIdentity() && + pathIsAbsent(cleanupTarget.transcriptPath) && + pathIsAbsent(cleanupTarget.retainedTranscriptSuccessorPath) && + pathIsAbsent(cleanupTarget.retainedTranscriptUnknownPath); const retainedTranscriptIdentityIsAbsentFromParent = (): boolean => { const transcriptParent = path.dirname(cleanupTarget.transcriptPath); const expectedParent = preauthorizedCleanup.transcriptParentIdentity; @@ -7741,31 +8141,81 @@ async function executeLifecycleResponse( preauthorizedCleanup, ), }); - let deleted: VerifiedSessionDeleteResult; - try { - const completedArtifactReplay = cleanup?.phase === "transcript" && cleanup.artifactsRemoved === true; - if (completedArtifactReplay && !pathIsAbsent(canonicalArtifactsPath)) + if (validated.taskArtifactOwnerCaptureError) + return await publishTaskArtifactOwnerPending(validated.taskArtifactOwnerCaptureError); + if ( + cleanup?.phase === "artifacts" && + cleanupTarget.artifactsRemoved === true && + cleanupTarget.taskArtifactOwnerDeletionEvidence && + cleanupTarget.taskArtifactOwnerRetired !== true + ) { + if (!pathIsAbsent(canonicalArtifactsPath)) return await publishCanonicalArtifactReappearance(preauthorizedCleanup); - if ( - completedArtifactReplay && - transcriptCleanupAuthorityIsAbsent() && - retainedTranscriptIdentityIsAbsentFromParent() - ) - return fail( - "cleanup_pending", - "Saved session cleanup remains pending because transcript authority disappeared without native deletion proof.", + const ownerPending = await persistFreshTaskArtifactOwnerRetirement(preauthorizedCleanup); + if (ownerPending) return ownerPending; + } + let deleted: VerifiedSessionDeleteResult | undefined; + if (cleanup?.taskArtifactOwnerTranscriptDeleted === true) { + if (!logicalTranscriptRetirementMatches()) + return await publishTaskArtifactOwnerPending( + "task_artifact_owner_transcript_disposition_mismatch", preauthorizedCleanup, ); - else { + if (cleanupTarget.taskArtifactOwnerPayloadRetired === true) { + const residual = fail( + "cleanup_pending", + "Transcript retirement is complete; the task-artifact owner namespace remains pending in artifacts.", + cleanupWithFreshOwnerPayload({ ...preauthorizedCleanup, phase: "artifacts", artifactsRemoved: true }), + ); + await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, response: residual }); + return residual; + } + if (cleanupTarget.taskArtifactOwnerRetired === true) { + const ownerEvidence = cleanupTarget.taskArtifactOwnerDeletionEvidence; + const ownerContext = cleanupTarget.taskArtifactOwnerStorageContext; + const ownerOutcome = cleanupTarget.taskArtifactOwnerRetirementOutcome; + if (!ownerEvidence || !ownerContext || !ownerOutcome) + return await publishTaskArtifactOwnerPending( + "task_artifact_owner_retired_evidence_missing", + preauthorizedCleanup, + ); + try { + verifyTaskArtifactOwnerPhysicalRetirement(ownerContext, ownerEvidence, ownerOutcome); + } catch (error) { + return await publishTaskArtifactOwnerPending(error, preauthorizedCleanup); + } + deleted = { kind: "deleted" }; + } + } + try { + if (deleted === undefined) { + const completedArtifactReplay = cleanup?.phase === "transcript" && cleanup.artifactsRemoved === true; + if (completedArtifactReplay && !pathIsAbsent(canonicalArtifactsPath)) + return await publishCanonicalArtifactReappearance(preauthorizedCleanup); + if ( + completedArtifactReplay && + transcriptCleanupAuthorityIsAbsent() && + retainedTranscriptIdentityIsAbsentFromParent() + ) + return fail( + "cleanup_pending", + "Saved session cleanup remains pending because transcript authority disappeared without native deletion proof.", + preauthorizedCleanup, + ); if (!transcriptParentMatchesPersistedIdentity()) return fail( "cleanup_pending", "Saved session cleanup is pending because transcript parent identity changed before exact mutation.", preauthorizedCleanup, ); - deleted = await validated.storage.deleteSessionVerified(cleanupTarget); + deleted = await validated.storage.deleteSessionVerified( + { ...cleanupTarget, ...storageOwnerFields() }, + validated.inspectProtocol, + ); } } catch (error) { + if (error instanceof Error && error.message.startsWith("task_artifact_owner_")) + return await publishTaskArtifactOwnerPending(error); if (error instanceof SessionDeleteVerificationError) { if (cleanup?.phase === "artifacts") return await publishChangedArtifactRoot( @@ -7784,6 +8234,7 @@ async function executeLifecycleResponse( `Unable to delete saved session artifacts: ${error instanceof Error ? error.message : String(error)}`, ); } + if (!deleted) return fail("unavailable", "Saved session deletion did not produce an outcome."); if ( deleted.kind === "deleted" && cleanup?.phase === "transcript" && @@ -7807,10 +8258,23 @@ async function executeLifecycleResponse( cleanupTarget.detachedArtifactsPath, "Saved session cleanup is pending in artifacts: an authorized quarantine alias remains after exact removal.", ); - const transcriptPhaseCleanup: CleanupEvidence = { + const returnedOwnerEvidence = deleted.taskArtifactOwnerDeletionEvidence; + if ( + cleanupTarget.taskArtifactOwnerDeletionEvidence && + returnedOwnerEvidence && + JSON.stringify(cleanupTarget.taskArtifactOwnerDeletionEvidence) !== JSON.stringify(returnedOwnerEvidence) + ) + return await publishTaskArtifactOwnerPending("task_artifact_owner_evidence_changed_during_delete"); + if (returnedOwnerEvidence && !cleanupTarget.taskArtifactOwnerDeletionEvidence) + return await publishTaskArtifactOwnerPending("task_artifact_owner_evidence_not_prepared_before_effect"); + const ownerEvidence = cleanupTarget.taskArtifactOwnerDeletionEvidence; + const artifactCompletionCleanup: CleanupEvidence = { ...preauthorizedCleanup, - phase: "transcript", + phase: ownerEvidence ? "artifacts" : "transcript", artifactsRemoved: true, + artifactsAbsentAtAuthorization: undefined, + taskArtifactOwnerCleanupError: undefined, + ...(ownerEvidence ? { taskArtifactOwnerDeletionEvidence: ownerEvidence } : {}), detachedArtifactsPath: undefined, retainedArtifactsSuccessorPath: undefined, retainedArtifactsPlaceholderPath: undefined, @@ -7826,12 +8290,42 @@ async function executeLifecycleResponse( } : {}), }; - await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, response: fail( "cleanup_pending", - "Saved session artifacts were removed; transcript cleanup is preauthorized.", + "Saved session artifacts are removed; owner cleanup is durably preauthorized.", + artifactCompletionCleanup, + ), + }); + if (ownerEvidence && cleanupTarget.taskArtifactOwnerRetired !== true) { + const ownerPending = await persistFreshTaskArtifactOwnerRetirement(artifactCompletionCleanup); + if (ownerPending) return ownerPending; + } + const ownerNamespaceRetained = + cleanupTarget.taskArtifactOwnerPayloadRetired === true && + cleanupTarget.taskArtifactOwnerNamespaceRetained === true; + const transcriptPhaseCleanup: CleanupEvidence = { + ...cleanupWithFreshOwnerPayload(artifactCompletionCleanup), + phase: ownerNamespaceRetained ? "artifacts" : "transcript", + artifactsRemoved: true, + artifactsAbsentAtAuthorization: undefined, + taskArtifactOwnerTranscriptDeleted: undefined, + ...(artifactCompletionCleanup.artifactTree + ? { + artifactTree: { + ...artifactCompletionCleanup.artifactTree, + detachedPath: undefined, + completed: true as const, + }, + } + : {}), + }; + await broker.ledger.transition(identity, "effect_started", { + intendedSessionId: id, + response: fail( + "cleanup_pending", + "Saved session artifacts and owner cleanup are durably recorded; transcript cleanup is preauthorized.", transcriptPhaseCleanup, ), }); @@ -7843,33 +8337,39 @@ async function executeLifecycleResponse( "Saved session cleanup remains pending because transcript authority disappeared without native deletion proof.", transcriptPhaseCleanup, ); - else { - if (!transcriptParentMatchesPersistedIdentity()) - return fail( - "cleanup_pending", - "Saved session cleanup is pending because transcript parent identity changed before exact mutation.", - transcriptPhaseCleanup, - ); - try { - deleted = await validated.storage.deleteSessionVerified({ + if (!transcriptParentMatchesPersistedIdentity()) + return fail( + "cleanup_pending", + "Saved session cleanup is pending because transcript parent identity changed before exact mutation.", + transcriptPhaseCleanup, + ); + try { + deleted = await validated.storage.deleteSessionVerified( + { ...cleanupTarget, + ...storageOwnerFields(), expectedArtifactsIdentity: undefined, detachedArtifactsPath: undefined, artifactsRemoved: true, - }); - } catch (error) { - if (error instanceof SessionDeleteVerificationError && error.kind === "artifacts") - return await publishCanonicalArtifactReappearance(transcriptPhaseCleanup); - if (error instanceof SessionDeleteVerificationError) - return fail( - "invalid_input", - `Saved session deletion verification failed (${error.kind}): ${error.message}`, - ); + }, + validated.inspectProtocol, + ); + } catch (error) { + if (error instanceof Error && error.message.startsWith("task_artifact_owner_")) + return await publishTaskArtifactOwnerPending(error, transcriptPhaseCleanup); + if (error instanceof SessionDeleteVerificationError && error.kind === "artifacts") + return await publishCanonicalArtifactReappearance(transcriptPhaseCleanup); + if (error instanceof SessionDeleteVerificationError) return fail( - "unavailable", - `Unable to delete saved session transcript: ${error instanceof Error ? error.message : String(error)}`, + "invalid_input", + `Saved session deletion verification failed (${error.kind}): ${error.message}`, ); - } + if (freshPayloadRetirementForThisAttempt) + return await publishTaskArtifactOwnerPending(error, transcriptPhaseCleanup); + return fail( + "unavailable", + `Unable to delete saved session transcript: ${error instanceof Error ? error.message : String(error)}`, + ); } if (deleted.kind === "deleted" && !pathIsAbsent(canonicalArtifactsPath)) return await publishCanonicalArtifactReappearance(transcriptPhaseCleanup); @@ -7885,13 +8385,45 @@ async function executeLifecycleResponse( "Saved session cleanup is pending in artifacts: a planned quarantine alias remains before terminal completion.", ); const retainedRootArtifactsPlan = durableArtifactsPlan; - if (deleted.kind === "cleanup_pending") - return fail( + if (deleted.kind === "cleanup_pending") { + const ownerEvidence = + deleted.taskArtifactOwnerDeletionEvidence ?? cleanupTarget.taskArtifactOwnerDeletionEvidence; + const ownerRetired = deleted.taskArtifactOwnerRetired ?? cleanupTarget.taskArtifactOwnerRetired; + const ownerPayloadRetired = + deleted.taskArtifactOwnerPayloadRetired ?? cleanupTarget.taskArtifactOwnerPayloadRetired; + const ownerNamespaceRetained = + deleted.taskArtifactOwnerNamespaceRetained ?? cleanupTarget.taskArtifactOwnerNamespaceRetained; + const retainedOwnerNamespace = ownerPayloadRetired === true && ownerNamespaceRetained === true; + const ownerContinuation = + deleted.taskArtifactOwnerRetirementContinuation ?? cleanupTarget.taskArtifactOwnerRetirementContinuation; + const ownerOutcome = deleted.taskArtifactOwnerRetirementOutcome + ? taskArtifactOwnerRetirementDisposition(deleted.taskArtifactOwnerRetirementOutcome) + : ownerRetirementOutcome; + const transcriptDeleted = + deleted.taskArtifactOwnerTranscriptDeleted === true || ownerTranscriptDeleted === true; + const ownerError = + deleted.phase === "task_artifact_owner" || deleted.error.message.startsWith("task_artifact_owner_") + ? taskArtifactOwnerFailureDiagnostic(deleted.error) + : undefined; + const publicPhase = + retainedOwnerNamespace || deleted.phase === "task_artifact_owner" ? "artifacts" : deleted.phase; + const pending = fail( "cleanup_pending", - `Saved session cleanup is pending in ${deleted.phase}: ${deleted.error.message}`, + `Saved session cleanup is pending in ${publicPhase}: ${deleted.error.message}`, { cleanupReceiptVersion: 1, - phase: deleted.phase === "task_artifact_owner" ? "artifacts" : deleted.phase, + phase: publicPhase, + ...(ownerEvidence && (deleted.phase === "task_artifact_owner" || retainedOwnerNamespace) + ? { artifactsRemoved: true } + : {}), + ...(ownerEvidence ? { taskArtifactOwnerDeletionEvidence: ownerEvidence } : {}), + ...(ownerContinuation ? { taskArtifactOwnerRetirementContinuation: ownerContinuation } : {}), + ...(ownerOutcome ? { taskArtifactOwnerRetirementOutcome: ownerOutcome } : {}), + ...(ownerPayloadRetired ? { taskArtifactOwnerPayloadRetired: true as const } : {}), + ...(ownerNamespaceRetained ? { taskArtifactOwnerNamespaceRetained: true as const } : {}), + ...(ownerRetired ? { taskArtifactOwnerRetired: true as const } : {}), + ...(transcriptDeleted ? { taskArtifactOwnerTranscriptDeleted: true as const } : {}), + ...(ownerError ? { taskArtifactOwnerCleanupError: ownerError } : {}), sessionId: validated.target.sessionId, sessionsRoot: validated.target.sessionsRoot, transcriptPath: validated.target.transcriptPath, @@ -7975,6 +8507,34 @@ async function executeLifecycleResponse( : {}), }, ); + await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, response: pending }); + return pending; + } + if ( + deleted.kind === "deleted" && + cleanupTarget.taskArtifactOwnerPayloadRetired === true && + cleanupTarget.taskArtifactOwnerNamespaceRetained === true + ) { + if (!logicalTranscriptRetirementMatches()) + return await publishTaskArtifactOwnerPending( + "task_artifact_owner_transcript_disposition_mismatch", + preauthorizedCleanup, + ); + if (record) await appendSessionDeletedEvidence(broker, record); + const residual = fail( + "cleanup_pending", + "Transcript retirement is complete; the task-artifact owner namespace remains pending in artifacts.", + { + ...cleanupWithFreshOwnerPayload(preauthorizedCleanup), + phase: "artifacts", + artifactsRemoved: true, + artifactsAbsentAtAuthorization: undefined, + taskArtifactOwnerTranscriptDeleted: true, + }, + ); + await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, response: residual }); + return residual; + } const completion = { ok: true, result: { sessionId: id } } as const; if (metadataCleanup.lifecycleFiles?.length) { diff --git a/packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts b/packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts new file mode 100644 index 00000000000..fceb6a61d7b --- /dev/null +++ b/packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts @@ -0,0 +1,686 @@ +import { expect, it, vi } from "bun:test"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as native from "@gajae-code/natives"; +import { safeRm } from "../../../scripts/safe-cleanup"; +import { + Broker, + type BrokerCleanupEvidence, + type BrokerResponse, + normalizeBrokerInput, +} from "../src/sdk/broker/broker"; +import { + type ManagedScope, + prepareManagedSessionScopeForWriteSync, + resolveManagedGcScopeForRead, + resolveManagedScopeForWrite, + taskArtifactOwnerStorageContextForScope, +} from "../src/session/internal/managed-session-scope"; +import { ManagedSessionDescendantStore } from "../src/session/internal/managed-session-storage"; +import { FileSessionStorage } from "../src/session/session-storage"; +import { ensureManagedTaskArtifactOwner } from "../src/session/task-artifact-owner"; +import { + OWNER_DELETION_SCHEMA_VERSION, + OWNER_SCHEMA_VERSION, + ownerIdForSession, + type TaskArtifactOwnerLocator, + type TaskArtifactOwnerStorageContext, +} from "../src/session/task-artifact-owner-codec"; + +type FileSnapshot = { + relativePath: string; + dev: bigint; + ino: bigint; + bytes: Buffer; +}; + +type OwnerFixture = { + root: string; + cwd: string; + agentDir: string; + scope: ManagedScope; + sessionsRoot: string; + sessionId: string; + transcript: string; + ownerDirectory: string; + ownerIdentity: { dev: bigint; ino: bigint }; + ownerLocator: TaskArtifactOwnerLocator; + ownerParentIdentity: { dev: bigint; ino: bigint }; + ownerFiles: FileSnapshot[]; + newerTranscript: string; + newerTranscriptIdentity: { dev: bigint; ino: bigint }; + newerTranscriptBytes: Buffer; + newerOwnerDirectory: string; + newerOwnerIdentity: { dev: bigint; ino: bigint }; + newerOwnerFiles: FileSnapshot[]; +}; + +async function regularFiles(root: string): Promise { + const files: FileSnapshot[] = []; + const visit = async (directory: string, prefix: string): Promise => { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const pathname = path.join(directory, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) throw new Error("Task-artifact owner fixture unexpectedly contains a symlink"); + if (entry.isDirectory()) { + await visit(pathname, relativePath); + continue; + } + if (!entry.isFile()) throw new Error("Task-artifact owner fixture contains a non-regular entry"); + const stat = await fs.lstat(pathname, { bigint: true }); + files.push({ relativePath, dev: stat.dev, ino: stat.ino, bytes: await fs.readFile(pathname) }); + } + }; + await visit(root, ""); + return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); +} + +function publishManagedTranscriptBytes( + scope: ManagedScope, + context: TaskArtifactOwnerStorageContext, + filename: string, + bytes: Uint8Array, +): string { + const store = new ManagedSessionDescendantStore(context.rootAuthority, scope.directoryPath); + try { + store.publishNoReplaceSync(filename, bytes); + } finally { + store.close(); + } + return path.join(scope.directoryPath, filename); +} + +function publishManagedTranscript( + scope: ManagedScope, + context: TaskArtifactOwnerStorageContext, + sessionId: string, + cwd: string, + locator: TaskArtifactOwnerLocator, +): string { + const timestamp = new Date().toISOString(); + const filename = `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`; + const header = { + type: "session", + version: 3, + id: sessionId, + timestamp, + cwd, + taskArtifactOwner: locator, + }; + return publishManagedTranscriptBytes(scope, context, filename, Buffer.from(`${JSON.stringify(header)}\n`, "utf8")); +} + +async function createOwnedSession( + scope: ManagedScope, + context: TaskArtifactOwnerStorageContext, + cwd: string, + payload: string, +): Promise<{ + sessionId: string; + transcript: string; + transcriptBytes: Buffer; + transcriptIdentity: { dev: bigint; ino: bigint }; + ownerDirectory: string; + ownerIdentity: { dev: bigint; ino: bigint }; + ownerLocator: TaskArtifactOwnerLocator; + ownerParentIdentity: { dev: bigint; ino: bigint }; + ownerFiles: FileSnapshot[]; +}> { + const sessionId = crypto.randomUUID(); + const owner = await ensureManagedTaskArtifactOwner(context, sessionId, undefined); + try { + await owner.manager.save(payload, "probe"); + } finally { + owner.manager.getManagedStore()?.close(); + } + const transcript = publishManagedTranscript(scope, context, sessionId, cwd, owner.locator); + const transcriptBytes = await fs.readFile(transcript); + const transcriptStat = await fs.stat(transcript, { bigint: true }); + const ownerStat = await fs.stat(owner.manager.dir, { bigint: true }); + const ownerParentStat = await fs.stat(path.dirname(owner.manager.dir), { bigint: true }); + const ownerFiles = await regularFiles(owner.manager.dir); + if (ownerFiles.length === 0) throw new Error("Expected owner payload files"); + return { + sessionId, + transcript, + transcriptBytes, + transcriptIdentity: { dev: transcriptStat.dev, ino: transcriptStat.ino }, + ownerDirectory: owner.manager.dir, + ownerIdentity: { dev: ownerStat.dev, ino: ownerStat.ino }, + ownerLocator: owner.locator, + ownerParentIdentity: { dev: ownerParentStat.dev, ino: ownerParentStat.ino }, + ownerFiles, + }; +} + +async function createFixture(): Promise { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "gjc-owner-sdk-delete-"))); + const cwd = path.join(root, "workspace"); + const agentDir = path.join(root, "profile"); + const sessionsRoot = path.join(agentDir, "sessions"); + await fs.mkdir(cwd, { mode: 0o700 }); + await fs.mkdir(agentDir, { mode: 0o700 }); + const resolved = resolveManagedScopeForWrite({ cwd, agentDir, sessionsRoot }); + if (resolved.kind !== "resolved") throw new Error(`Expected managed owner scope: ${resolved.code}`); + const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope); + if (prepared.kind !== "resolved") throw new Error(`Expected prepared owner scope: ${prepared.code}`); + const scope = prepared.scope; + const context = taskArtifactOwnerStorageContextForScope(scope); + const original = await createOwnedSession(scope, context, cwd, "original task output"); + const newer = await createOwnedSession(scope, context, cwd, "newer output stays"); + + return { + root, + cwd, + agentDir, + sessionsRoot, + scope, + sessionId: original.sessionId, + transcript: original.transcript, + ownerDirectory: original.ownerDirectory, + ownerIdentity: original.ownerIdentity, + ownerLocator: original.ownerLocator, + ownerParentIdentity: original.ownerParentIdentity, + ownerFiles: original.ownerFiles, + newerTranscript: newer.transcript, + newerTranscriptIdentity: newer.transcriptIdentity, + newerTranscriptBytes: newer.transcriptBytes, + newerOwnerDirectory: newer.ownerDirectory, + newerOwnerIdentity: newer.ownerIdentity, + newerOwnerFiles: newer.ownerFiles, + }; +} + +function cleanupOf(response: BrokerResponse): BrokerCleanupEvidence { + if (response.ok || !response.error.cleanup) throw new Error("Expected a durable cleanup response"); + return response.error.cleanup; +} + +function expectPayloadRetired(response: BrokerResponse, sessionId: string): BrokerCleanupEvidence { + expect(response.ok).toBe(false); + if (response.ok) throw new Error("Expected retained namespace cleanup to remain pending"); + expect(response.error).toMatchObject({ code: "cleanup_pending" }); + const cleanup = cleanupOf(response); + expect(cleanup).toMatchObject({ + phase: "artifacts", + artifactsRemoved: true, + sessionId, + taskArtifactOwnerPayloadRetired: true, + taskArtifactOwnerNamespaceRetained: true, + taskArtifactOwnerTranscriptDeleted: true, + }); + expect(cleanup.taskArtifactOwnerDeletionEvidence).toMatchObject({ + schemaVersion: OWNER_DELETION_SCHEMA_VERSION, + sessionId, + }); + expect(cleanup.taskArtifactOwnerRetirementContinuation).toMatchObject({ schemaVersion: 1 }); + expect(cleanup.taskArtifactOwnerRetirementOutcome).toMatchObject({ + kind: "payload_retired", + namespace: "retained", + nativeOutcome: { ok: false, code: "cleanup_pending", payloadDurable: true }, + }); + return cleanup; +} + +async function verifyRetainedOwner(fixture: OwnerFixture, cleanup: BrokerCleanupEvidence): Promise { + const evidence = cleanup.taskArtifactOwnerDeletionEvidence; + const continuation = cleanup.taskArtifactOwnerRetirementContinuation; + if (!evidence || !continuation) throw new Error("Retained owner receipt lacks immutable evidence or continuation"); + const retainedRoot = continuation.retainedRootPath; + const retainedStat = await fs.lstat(retainedRoot, { bigint: true }); + const parentStat = await fs.stat(path.dirname(fixture.ownerDirectory), { bigint: true }); + expect({ dev: retainedStat.dev, ino: retainedStat.ino }).toEqual(fixture.ownerIdentity); + expect({ dev: parentStat.dev, ino: parentStat.ino }).toEqual(fixture.ownerParentIdentity); + expect(evidence.parentIdentity).toEqual({ + dev: fixture.ownerParentIdentity.dev.toString(), + ino: fixture.ownerParentIdentity.ino.toString(), + }); + expect({ rootDev: evidence.treeSnapshot.rootDev, rootIno: evidence.treeSnapshot.rootIno }).toEqual({ + rootDev: fixture.ownerIdentity.dev.toString(), + rootIno: fixture.ownerIdentity.ino.toString(), + }); + expect({ + rootDev: continuation.retainedTreeSnapshot.rootDev, + rootIno: continuation.retainedTreeSnapshot.rootIno, + }).toEqual({ rootDev: fixture.ownerIdentity.dev.toString(), rootIno: fixture.ownerIdentity.ino.toString() }); + expect(retainedRoot).toBe(`${fixture.ownerDirectory}.removing`); + await expect(fs.lstat(fixture.ownerDirectory)).rejects.toMatchObject({ code: "ENOENT" }); + + const originalByPath = new Map(fixture.ownerFiles.map(file => [file.relativePath, file])); + const retainedFiles = await regularFiles(retainedRoot); + expect(retainedFiles.length).toBeGreaterThan(0); + for (const file of retainedFiles) { + const original = originalByPath.get(file.relativePath); + if (!original) throw new Error(`Unexpected retained owner payload path: ${file.relativePath}`); + expect({ dev: file.dev, ino: file.ino }).toEqual({ dev: original.dev, ino: original.ino }); + expect(file.bytes.byteLength).toBe(0); + } + const emptyHash = crypto.createHash("sha256").update("").digest("hex"); + expect( + continuation.retainedTreeSnapshot.entries.every( + entry => entry.kind === "directory" || (entry.size === "0" && entry.sha256 === emptyHash), + ), + ).toBe(true); +} + +async function rejectOwnerReadoption(fixture: OwnerFixture, cleanup: BrokerCleanupEvidence): Promise { + const evidence = cleanup.taskArtifactOwnerDeletionEvidence; + if (!evidence || !cleanup.sessionsRoot) + throw new Error("Expected immutable owner evidence and managed sessions root"); + const resolved = resolveManagedGcScopeForRead({ + cwd: fixture.cwd, + agentDir: fixture.agentDir, + sessionsRoot: cleanup.sessionsRoot, + }); + if (resolved.kind !== "resolved") throw new Error("Expected the original verified managed scope"); + const context = taskArtifactOwnerStorageContextForScope(resolved.scope); + await expect(ensureManagedTaskArtifactOwner(context, fixture.sessionId, evidence.locator)).rejects.toThrow(); + await expect(fs.lstat(fixture.ownerDirectory)).rejects.toMatchObject({ code: "ENOENT" }); +} + +async function preserveNewerSession(fixture: OwnerFixture): Promise { + const newerStat = await fs.stat(fixture.newerOwnerDirectory, { bigint: true }); + expect({ dev: newerStat.dev, ino: newerStat.ino }).toEqual(fixture.newerOwnerIdentity); + const transcriptStat = await fs.stat(fixture.newerTranscript, { bigint: true }); + expect({ dev: transcriptStat.dev, ino: transcriptStat.ino }).toEqual(fixture.newerTranscriptIdentity); + expect(await fs.readFile(fixture.newerTranscript)).toEqual(fixture.newerTranscriptBytes); + const newerFiles = await regularFiles(fixture.newerOwnerDirectory); + expect(newerFiles.map(file => [file.relativePath, file.dev, file.ino, file.bytes])).toEqual( + fixture.newerOwnerFiles.map(file => [file.relativePath, file.dev, file.ino, file.bytes]), + ); +} + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +type PausedOwnerWriter = { release(): Promise }; + +async function pauseOwnerWriter(fixture: OwnerFixture, phase: "staging" | "replacement"): Promise { + const replacementTarget = + fixture.ownerFiles.find(file => !file.relativePath.startsWith(".")) ?? fixture.ownerFiles[0]!; + const readyPath = path.join(fixture.root, `writer-${phase}.ready`); + const releasePath = path.join(fixture.root, `writer-${phase}.release`); + const fixturePath = path.join(import.meta.dir, "fixtures", "task-owner-access-writer.ts"); + const child = Bun.spawn([process.execPath, fixturePath], { + cwd: path.resolve(import.meta.dir, ".."), + env: { + ...process.env, + GJC_TASK_OWNER_ACCESS_WRITER_INPUT: JSON.stringify({ + profileRoot: fixture.agentDir, + ownerRoot: fixture.ownerDirectory, + filename: phase === "replacement" ? path.basename(replacementTarget.relativePath) : "pending.bin", + ready: readyPath, + release: releasePath, + phase, + }), + }, + stdout: "pipe", + stderr: "pipe", + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + const deadline = Date.now() + 10_000; + while (!(await Bun.file(readyPath).exists())) { + if (Date.now() >= deadline) throw new Error(`writer ${phase} readiness timed out`); + const exited = await Promise.race([ + child.exited.then(code => ({ done: true as const, code })), + Bun.sleep(10).then(() => ({ done: false as const })), + ]); + if (exited.done) + throw new Error(`writer ${phase} exited before readiness (${exited.code}): ${await stderrPromise}`); + } + const release = async (): Promise => { + await Bun.write(releasePath, "release"); + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + expect(exitCode, `writer ${phase} failed: ${stderr}`).toBe(0); + expect(stdout).toContain(`"phase":"${phase}"`); + expect(stdout).toContain('"status":"acknowledged"'); + }; + return { release }; +} + +function deleteRequest(fixture: OwnerFixture, sessionId = fixture.sessionId, sessionPath = fixture.transcript) { + return { + cwd: fixture.cwd, + stateRoot: path.join(fixture.cwd, ".gjc", "state"), + sessionId, + sessionPath, + }; +} + +it("SDK refuses an unauthenticated protocol alias before artifact or owner payload effects", async () => { + const fixture = await createFixture(); + const broker = new Broker({ agentDir: fixture.agentDir }); + const artifacts = fixture.transcript.slice(0, -6); + await fs.mkdir(artifacts, { mode: 0o700 }); + await Bun.write(path.join(artifacts, "retained.txt"), "retained artifact"); + await fs.mkdir(path.join(fixture.scope.directoryPath, ".gjc-managed-session-internal.saved"), { mode: 0o700 }); + const transcriptBefore = await fs.readFile(fixture.transcript); + const artifactsBefore = await regularFiles(artifacts); + const ownerBefore = await regularFiles(fixture.ownerDirectory); + const ownerStatBefore = await fs.lstat(fixture.ownerDirectory, { bigint: true }); + const artifactsStatBefore = await fs.lstat(artifacts, { bigint: true }); + try { + await broker.start(); + const response = await broker.handleRequest( + "session.delete", + deleteRequest(fixture), + "unauthenticated-protocol-delete", + ); + expect(response).toMatchObject({ ok: false, error: { code: "cleanup_pending" } }); + const cleanup = cleanupOf(response); + expect(cleanup.phase).toBe("artifacts"); + expect(cleanup.taskArtifactOwnerRetired).toBeUndefined(); + expect(cleanup.taskArtifactOwnerTranscriptDeleted).toBeUndefined(); + expect(await fs.readFile(fixture.transcript)).toEqual(transcriptBefore); + expect(await regularFiles(artifacts)).toEqual(artifactsBefore); + expect(await regularFiles(fixture.ownerDirectory)).toEqual(ownerBefore); + for (const [pathname, before] of [ + [artifacts, artifactsStatBefore], + [fixture.ownerDirectory, ownerStatBefore], + ] as const) { + const after = await fs.lstat(pathname, { bigint: true }); + expect({ dev: after.dev, ino: after.ino, mode: after.mode, ctimeNs: after.ctimeNs }).toEqual({ + dev: before.dev, + ino: before.ino, + mode: before.mode, + ctimeNs: before.ctimeNs, + }); + } + await preserveNewerSession(fixture); + } finally { + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); + +it("rejects caller-supplied owner cleanup state on public session.delete", () => { + for (const input of [ + { + sessionId: "00000000-0000-4000-8000-000000000000", + taskArtifactOwnerRetired: true, + }, + { + sessionId: "00000000-0000-4000-8000-000000000000", + cleanup: { taskArtifactOwnerDeletionEvidence: {} }, + }, + { + sessionId: "00000000-0000-4000-8000-000000000000", + target: { taskArtifactOwnerRetirementOutcome: { kind: "completed" } }, + }, + ]) { + expect(normalizeBrokerInput("session.delete", input)).toMatchObject({ + ok: false, + error: { code: "invalid_input" }, + }); + } +}); + +it("SDK deletion keeps an orphan owner locator pending without claiming retirement", async () => { + const fixture = await createFixture(); + const broker = new Broker({ agentDir: fixture.agentDir }); + const orphanSessionId = crypto.randomUUID(); + const orphanLocator: TaskArtifactOwnerLocator = { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: ownerIdForSession(orphanSessionId), + directoryDev: "1", + directoryIno: "2", + }; + const orphanTranscript = publishManagedTranscript( + fixture.scope, + taskArtifactOwnerStorageContextForScope(fixture.scope), + orphanSessionId, + fixture.cwd, + orphanLocator, + ); + const originalBytes = await fs.readFile(orphanTranscript); + const deleteSpy = vi.spyOn(FileSessionStorage.prototype, "deleteSessionVerified"); + try { + await broker.start(); + const response = await broker.handleRequest( + "session.delete", + deleteRequest(fixture, orphanSessionId, orphanTranscript), + "orphan-owner-delete", + ); + const cleanup = cleanupOf(response); + expect(response).toMatchObject({ ok: false, error: { code: "cleanup_pending" } }); + expect(cleanup).toMatchObject({ + phase: "artifacts", + taskArtifactOwnerCleanupError: "task_artifact_owner_missing", + }); + expect(cleanup.taskArtifactOwnerDeletionEvidence).toBeUndefined(); + expect(cleanup.taskArtifactOwnerRetirementOutcome).toBeUndefined(); + expect(cleanup.taskArtifactOwnerRetired).toBeUndefined(); + expect(cleanup.taskArtifactOwnerPayloadRetired).toBeUndefined(); + expect(cleanup.taskArtifactOwnerNamespaceRetained).toBeUndefined(); + expect(cleanup.taskArtifactOwnerTranscriptDeleted).toBeUndefined(); + expect(await fs.readFile(orphanTranscript)).toEqual(originalBytes); + expect(deleteSpy).not.toHaveBeenCalled(); + } finally { + deleteSpy.mockRestore(); + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); + +it("SDK deletion preserves a scrubbed owner continuation across restart and keeps transcript deletion pending", async () => { + const fixture = await createFixture(); + let broker = new Broker({ agentDir: fixture.agentDir }); + try { + await broker.start(); + const request = deleteRequest(fixture); + const cleanup = expectPayloadRetired( + await broker.handleRequest("session.delete", request, "owned-session-delete"), + fixture.sessionId, + ); + expect(await Bun.file(fixture.transcript).exists()).toBe(false); + await verifyRetainedOwner(fixture, cleanup); + await rejectOwnerReadoption(fixture, cleanup); + await preserveNewerSession(fixture); + + await broker.stop(); + broker = new Broker({ agentDir: fixture.agentDir }); + await broker.start(); + const replay = await broker.handleRequest("session.delete", request, "owned-session-delete"); + expect(await Bun.file(fixture.transcript).exists()).toBe(false); + const replayCleanup = expectPayloadRetired(replay, fixture.sessionId); + expect(replayCleanup.taskArtifactOwnerDeletionEvidence).toEqual(cleanup.taskArtifactOwnerDeletionEvidence); + expect(replayCleanup.taskArtifactOwnerRetirementContinuation?.retainedRootPath).toBe( + cleanup.taskArtifactOwnerRetirementContinuation?.retainedRootPath, + ); + await verifyRetainedOwner(fixture, replayCleanup); + await preserveNewerSession(fixture); + } finally { + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); + +it.each([ + "staging", + "replacement", +] as const)("SDK delete refuses a real managed %s writer during initial and restored owner validation", async phase => { + const fixture = await createFixture(); + let broker = new Broker({ agentDir: fixture.agentDir }); + let writer: PausedOwnerWriter | undefined; + let restoreTransition: (() => void) | undefined; + const originalTranscript = await fs.readFile(fixture.transcript); + const ownerStat = await fs.lstat(fixture.ownerDirectory, { bigint: true }); + const originalOwnerFiles = new Map(fixture.ownerFiles.map(file => [file.relativePath, file])); + const nativeSpy = vi.spyOn(native, "exactRemoveDirectoryTree"); + try { + await broker.start(); + const transition = broker.ledger.transition.bind(broker.ledger); + const transitionSpy = vi + .spyOn(broker.ledger, "transition") + .mockImplementation(async (identity, state, fields) => { + const result = await transition(identity, state, fields); + const response = record(fields?.response); + const cleanup = record(record(response?.error)?.cleanup); + if (!writer && cleanup?.taskArtifactOwnerDeletionEvidence !== undefined) + writer = await pauseOwnerWriter(fixture, phase); + return result; + }); + restoreTransition = () => transitionSpy.mockRestore(); + const request = deleteRequest(fixture); + const first = await broker.handleRequest("session.delete", request, `owner-${phase}-delete`); + const firstCleanup = cleanupOf(first); + expect(firstCleanup).toMatchObject({ + phase: "artifacts", + taskArtifactOwnerDeletionEvidence: { schemaVersion: OWNER_DELETION_SCHEMA_VERSION }, + taskArtifactOwnerCleanupError: "task_artifact_owner_writer_not_quiescent", + }); + expect(firstCleanup.taskArtifactOwnerRetirementOutcome).toBeUndefined(); + expect(firstCleanup.taskArtifactOwnerTranscriptDeleted).toBeUndefined(); + expect(await fs.readFile(fixture.transcript)).toEqual(originalTranscript); + const ownerDuringWrite = await fs.lstat(fixture.ownerDirectory, { bigint: true }); + expect({ dev: ownerDuringWrite.dev, ino: ownerDuringWrite.ino }).toEqual({ + dev: ownerStat.dev, + ino: ownerStat.ino, + }); + for (const file of await regularFiles(fixture.ownerDirectory)) { + const original = originalOwnerFiles.get(file.relativePath); + if (!original) continue; + expect({ dev: file.dev, ino: file.ino, bytes: file.bytes }).toEqual({ + dev: original.dev, + ino: original.ino, + bytes: original.bytes, + }); + } + expect( + nativeSpy.mock.calls.filter( + ([pathname]) => pathname === fixture.ownerDirectory || pathname === `${fixture.ownerDirectory}.removing`, + ), + ).toHaveLength(0); + restoreTransition(); + restoreTransition = undefined; + + await broker.stop(); + broker = new Broker({ agentDir: fixture.agentDir }); + await broker.start(); + const replay = await broker.handleRequest("session.delete", request, `owner-${phase}-delete`); + const replayCleanup = cleanupOf(replay); + expect(replayCleanup).toMatchObject({ + phase: "artifacts", + taskArtifactOwnerDeletionEvidence: firstCleanup.taskArtifactOwnerDeletionEvidence, + taskArtifactOwnerCleanupError: "task_artifact_owner_writer_not_quiescent", + }); + expect(await fs.readFile(fixture.transcript)).toEqual(originalTranscript); + expect( + nativeSpy.mock.calls.filter( + ([pathname]) => pathname === fixture.ownerDirectory || pathname === `${fixture.ownerDirectory}.removing`, + ), + ).toHaveLength(0); + } finally { + restoreTransition?.(); + if (writer) await writer.release(); + nativeSpy.mockRestore(); + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); + +it("SDK deletion refuses a shared owner when a sibling transcript carries the same locator", async () => { + const fixture = await createFixture(); + const broker = new Broker({ agentDir: fixture.agentDir }); + const originalTranscript = await fs.readFile(fixture.transcript); + const ownerBefore = await regularFiles(fixture.ownerDirectory); + const nativeSpy = vi.spyOn(native, "exactRemoveDirectoryTree"); + try { + const siblingId = crypto.randomUUID(); + publishManagedTranscriptBytes( + fixture.scope, + taskArtifactOwnerStorageContextForScope(fixture.scope), + `${siblingId}.jsonl`, + Buffer.from( + `${JSON.stringify({ + type: "session", + version: 3, + id: siblingId, + timestamp: new Date().toISOString(), + cwd: fixture.cwd, + taskArtifactOwner: fixture.ownerLocator, + })}\n`, + "utf8", + ), + ); + await broker.start(); + const response = await broker.handleRequest("session.delete", deleteRequest(fixture), "shared-owner-delete"); + const cleanup = cleanupOf(response); + expect(cleanup).toMatchObject({ + phase: "artifacts", + artifactsRemoved: true, + taskArtifactOwnerCleanupError: "task_artifact_owner_shared_with_sibling_transcript", + taskArtifactOwnerDeletionEvidence: { schemaVersion: OWNER_DELETION_SCHEMA_VERSION }, + }); + expect(await fs.readFile(fixture.transcript)).toEqual(originalTranscript); + expect(await regularFiles(fixture.ownerDirectory)).toEqual(ownerBefore); + expect( + nativeSpy.mock.calls.filter( + ([pathname]) => pathname === fixture.ownerDirectory || pathname === `${fixture.ownerDirectory}.removing`, + ), + ).toHaveLength(0); + await preserveNewerSession(fixture); + } finally { + nativeSpy.mockRestore(); + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); + +it("SDK owner replay leaves a replacement retained root untouched", async () => { + const fixture = await createFixture(); + let broker = new Broker({ agentDir: fixture.agentDir }); + const deleteSpy = vi.spyOn(FileSessionStorage.prototype, "deleteSessionVerified"); + try { + await broker.start(); + const request = deleteRequest(fixture); + const cleanup = expectPayloadRetired( + await broker.handleRequest("session.delete", request, "replaced-owner-delete"), + fixture.sessionId, + ); + const retainedPath = cleanup.taskArtifactOwnerRetirementContinuation?.retainedRootPath; + if (!retainedPath) throw new Error("Expected a retained owner root"); + const retainedStat = await fs.lstat(retainedPath, { bigint: true }); + const movedOriginal = `${retainedPath}.test-original`; + await fs.rename(retainedPath, movedOriginal); + await fs.mkdir(retainedPath); + const replacementFile = path.join(retainedPath, "replacement-marker"); + await fs.writeFile(replacementFile, "new remnant identity"); + const replacementStat = await fs.lstat(retainedPath, { bigint: true }); + const replacementBytes = await fs.readFile(replacementFile); + deleteSpy.mockClear(); + + await broker.stop(); + broker = new Broker({ agentDir: fixture.agentDir }); + await broker.start(); + const replay = await broker.handleRequest("session.delete", request, "replaced-owner-delete"); + expect(replay).toMatchObject({ + ok: false, + error: { code: "cleanup_pending", cleanup: { phase: "artifacts", taskArtifactOwnerTranscriptDeleted: true } }, + }); + expect(deleteSpy).not.toHaveBeenCalled(); + const afterReplacement = await fs.lstat(retainedPath, { bigint: true }); + expect({ dev: afterReplacement.dev, ino: afterReplacement.ino }).toEqual({ + dev: replacementStat.dev, + ino: replacementStat.ino, + }); + expect(await fs.readFile(replacementFile)).toEqual(replacementBytes); + const originalAfterMove = await fs.lstat(movedOriginal, { bigint: true }); + expect({ dev: originalAfterMove.dev, ino: originalAfterMove.ino }).toEqual({ + dev: retainedStat.dev, + ino: retainedStat.ino, + }); + await preserveNewerSession(fixture); + } finally { + deleteSpy.mockRestore(); + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/coding-agent/test/task-artifact-owner-storage.test.ts b/packages/coding-agent/test/task-artifact-owner-storage.test.ts index ed4b957b59c..96d8ebd865b 100644 --- a/packages/coding-agent/test/task-artifact-owner-storage.test.ts +++ b/packages/coding-agent/test/task-artifact-owner-storage.test.ts @@ -560,15 +560,15 @@ describe("verified storage consumes task artifact owners", () => { const fixture = await makeFixture(); const nativeRemoval = recordActualOwnerRemoval(); const target = targetFor(fixture, { deferTaskArtifactOwnerRetirement: true }); - const unauthenticated = await fixture.storage.deleteSessionVerified(target); - expect(unauthenticated).toMatchObject({ kind: "cleanup_pending", phase: "task_artifact_owner" }); + const transcriptBefore = fs.readFileSync(fixture.transcriptPath); + const payloadBefore = fs.readFileSync(fixture.ownerPayloadPath); + const untrusted = await fixture.storage.deleteSessionVerified(target); + expect(untrusted).toMatchObject({ kind: "cleanup_pending", phase: "task_artifact_owner" }); + expect(fs.readFileSync(fixture.transcriptPath)).toEqual(transcriptBefore); + expect(fs.readFileSync(fixture.ownerPayloadPath)).toEqual(payloadBefore); expect(nativeRemoval.spy).not.toHaveBeenCalled(); - expect(fs.existsSync(fixture.ownerPayloadPath)).toBe(true); - expect(fs.existsSync(fixture.transcriptPath)).toBe(true); - const before = await fixture.storage.deleteSessionVerified( - target, - managedGcProtocolScopeInspectorForScope(fixture.scope), - ); + const inspectProtocol = managedGcProtocolScopeInspectorForScope(fixture.scope); + const before = await fixture.storage.deleteSessionVerified(target, inspectProtocol); expect(before.kind).toBe("artifacts_removed"); expect(before.taskArtifactOwnerRetirementOutcome).toBeUndefined(); expect(nativeRemoval.spy).not.toHaveBeenCalled(); @@ -583,7 +583,7 @@ describe("verified storage consumes task artifact owners", () => { artifactsRemoved: true, deferTaskArtifactOwnerRetirement: true, }), - managedGcProtocolScopeInspectorForScope(fixture.scope), + inspectProtocol, ); expect(after.taskArtifactOwnerRetirementOutcome).toEqual(outcome); if (outcome.kind === "completed") {