mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 14:26:07 +00:00
fix(mcp): name the close_binary retry for retained cleanup (#8329)
* fix(mcp): name the close_binary retry for retained cleanup A close_binary that retired its target but could not confirm cleanup returned cleanup_incomplete with retryable: false and only the generic "Review the reported local resources" remediation, although the session keeps every failed owner and the next close_binary on the same connection retries them. Agents could abandon the connection that owns the only retry. The MCP close tool now passes its retry operation to the session. The session binds that typed AnalysisCleanupRetry to a failed retirement only while it retains the cleanup for its next close; the close tool binds it to process-capture failures its own scope retains and to compositions whose session part was offered. Projection then reports recovery.kind=retry_cleanup and appends a conditional instruction: keep the connection, let the condition settle or resolve it, call close_binary without snapshot_path and check the result. The hint disappears once that cleanup resolves. retryable keeps describing the failed operation. One-shot CLI closes, open_binary, analysis tools and failed snapshot saves (which keep the target open) offer no retry. Cleanup composition copies the owner's remediation without a retry offer, so the text appears once. Fixes #8211 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: derive close retry guidance from its owning formatters The new close_binary retry assertions froze ProviderCleanupError's explanatory sentence, so a harmless wording edit would break them. The MCP test now compares delivered remediation with analysisErrorRemediationAction applied to the delivered typed error plus cleanupRetryRemediation. The domain tests use the same formatters, check that unbound and released errors carry no retry text, and check that a bound composition adds the offer once. Recovery fields, cleanup state and retry behavior keep separate assertions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c534e249d1
commit
8a6ddec83a
16 files changed
+659
-32
No files matched your search
@@ -52,6 +52,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -115,6 +124,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -178,6 +196,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -241,6 +268,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -304,6 +340,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -367,6 +412,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -430,6 +484,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -493,6 +556,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -556,6 +628,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -619,6 +700,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -682,6 +772,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -819,6 +918,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -882,6 +990,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -945,6 +1062,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1008,6 +1134,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1071,6 +1206,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1134,6 +1278,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1197,6 +1350,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1260,6 +1422,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1323,6 +1494,15 @@
|
||||
},
|
||||
"required": ["kind", "operation", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "retry_cleanup" },
|
||||
"operation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["kind", "operation"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -137,7 +137,7 @@ the manifest's file identity as well as the export files. These refusal and
|
||||
migration cases are verified with controlled Kuna-format exports; they do not
|
||||
establish behavior of an independently installed Kuna release.
|
||||
|
||||
Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. REA retains the file state verified with each digest, checks it before warm-cache reuse, and checks open descriptors before and after reading text. Assembly indexes are checked against the same state; a changed file makes the whole report retry rather than mixing facets from different exports. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it.
|
||||
Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. REA retains the file state verified with each digest, checks it before warm-cache reuse, and checks open descriptors before and after reading text. Assembly indexes are checked against the same state; a changed file makes the whole report retry rather than mixing facets from different exports. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven; that MCP error names the same-connection retry as `recovery.kind=retry_cleanup` ([retained cleanup retry](mcp-contracts.md#retained-cleanup-retry)). Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it.
|
||||
|
||||
## Verify with a real Kuna
|
||||
|
||||
|
||||
@@ -173,6 +173,9 @@ recommendation to change it. When it names no setting, reduce supported work;
|
||||
do not invent one. Raising only the MCP
|
||||
client's deadline cannot extend this budget. These hints never include the
|
||||
complete catalog or caller arguments.
|
||||
`recovery.kind=retry_cleanup` appears only on a `close_binary`
|
||||
`cleanup_incomplete` error whose retained resources the session will retry; see
|
||||
[retained cleanup](#retained-cleanup-retry).
|
||||
Failures omit `structuredContent`, since each advertised `outputSchema`
|
||||
describes successful results. Successful replies retain their schema-validated
|
||||
structured data and matching text projection. Oversized errors are retained
|
||||
@@ -861,6 +864,20 @@ inspection fails or is cancelled before a provider client exists. If no client
|
||||
remains, no provider run or health observation is available; follow the cleanup
|
||||
error's reported resources.
|
||||
|
||||
### Retained cleanup retry
|
||||
|
||||
A `close_binary` that retires the target clears session records even when
|
||||
cleanup fails. When the session keeps every reported owner for the next
|
||||
`close_binary` on the same connection, its `cleanup_incomplete` error carries
|
||||
`recovery: {"kind": "retry_cleanup", "operation": "close_binary"}`.
|
||||
Keep the connection open, let the reported condition settle or resolve it, call
|
||||
`close_binary` without `snapshot_path`, and check the result. The retry does not
|
||||
rerun the failed analysis and can report `cleanup_incomplete` again.
|
||||
`retryable` still describes the failed operation and stays `false`. A failed
|
||||
`snapshot_path` save returns before retirement, keeps the target open and has
|
||||
no `retry_cleanup` hint. `open_binary`, analysis tools and one-shot CLI commands
|
||||
do not report this hint either, even when their cleanup remains pending.
|
||||
|
||||
## Initial target at startup
|
||||
|
||||
MCP normally starts without a target; use `open_binary` to select one during the
|
||||
|
||||
@@ -50,6 +50,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement
|
||||
36,src/main/reportMcpTransportError.ts:reportMcpTransportError,MCP error has no known validation or disconnection identity,MCP reported an error with unknown connection state. Check whether the client is still connected before following connection-loss recovery.,Connection state remains unknown,Keep current copy,implemented,injected unknown-error recovery regression
|
||||
37,src/main.ts:serveStdio throw,MCP connection cannot be created,Failed to start MCP stdio transport,Exposes transport implementation and gives no recovery,"Retry once. If it fails again, tell the user REA could not start its MCP connection and ask them to restart the MCP client, then run `rea doctor`.",implemented,verified by injected transport startup-throw runtime test
|
||||
38,src/main.ts:requestShutdown catch,Connection or analysis session close rejects,Failed to shut down cleanly,Does not explain duplicate-process risk or recovery,REA could not close cleanly. End the REA process before starting it again.,implemented,verified by injected handle-close rejection runtime test
|
||||
38.5,src/domain/analysisErrorRecovery.ts:cleanupRetryRemediation,close_binary returns cleanup_incomplete while the session retains the failed cleanup owners,"Provider cleanup could not be fully confirmed. Review the reported local resources before opening another target. (retryable: false, no recovery)",Agent could abandon the connection that owns the only cleanup retry,"Append: This connection retains that cleanup. Keep it open; after the reported condition settles or is resolved, call close_binary without snapshot_path and check its result. That call retries the retained cleanup without rerunning the failed analysis, clears session records like any close_binary, and can report cleanup_incomplete again. Add recovery.kind=retry_cleanup naming close_binary; keep retryable false",implemented,MCP close_binary with the real KunaProvider and a fake Kuna whose export stop stays unproven; process-capture MCP retry; session tests for an unoffered one-shot close and for failed and completed snapshot saves; one-shot IDA CLI cleanup reports no hint; real Kuna not run
|
||||
39,src/main.ts:entrypoint catch,Unhandled rejection escapes run(),Unexpected server startup failure,Vague and lacks diagnostic recovery,"Do not repeat this startup. Tell the user REA could not start and ask them to restart the MCP client, then run `rea doctor`.",implemented,verified by injected entrypoint rejection test
|
||||
40,src/application/Update.ts:unknown-install-method,Running package is not owned by detectable global npm installation,Update manually with: npm install --global rea-agents@latest,Old global-npm advice does not identify the owning installation,"Update this installation with the package manager or source checkout that owns it. For a one-off latest invocation, use npx rea-agents@latest.",implemented,36 Update tests passed; public CLI unowned-installation refusal verified with isolated package-manager fixtures
|
||||
41,src/application/Update.ts:install,npm global install subprocess exits unsuccessfully,npm install --global rea-agents@latest failed.,Old registry/global-install advice omits the actual version prefix and installer failure,"REA could not install {version} in {prefix}: {failure}. Repair this npm installation, then rerun rea update.",implemented,36 Update tests passed; public CLI owned npm fixture failure preserved version prefix and installer reason
|
||||
|
||||
|
@@ -60,6 +60,7 @@ import {
|
||||
} from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "../AnalysisClientCleanup.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import type { AnalysisCleanupRetry } from "../../domain/analysisErrorRecovery.js";
|
||||
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js";
|
||||
@@ -867,11 +868,15 @@ export class BinarySession
|
||||
close(
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
readonly cleanupRetry?: AnalysisCleanupRetry;
|
||||
} = {},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
await this.#drainCalls(admittedThrough);
|
||||
return this.#closeActive(options);
|
||||
return this.#offerCleanupRetry(
|
||||
await this.#closeActive(options),
|
||||
options.cleanupRetry,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -879,7 +884,9 @@ export class BinarySession
|
||||
closeWithSnapshot(
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
options: Pick<ExecutionOptions, "progress"> = {},
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly cleanupRetry?: AnalysisCleanupRetry;
|
||||
} = {},
|
||||
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>> {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
await this.#drainCalls(admittedThrough);
|
||||
@@ -897,13 +904,43 @@ export class BinarySession
|
||||
workflow_entries: snapshot.value.workflow_entries.length,
|
||||
evidence_records: snapshot.value.evidence_bundle.records.length,
|
||||
};
|
||||
const closed = await this.#closeActive(options);
|
||||
// A failed export or save returned above with the target still open, so
|
||||
// only a failed retirement can offer the caller's cleanup retry.
|
||||
const closed = this.#offerCleanupRetry(
|
||||
await this.#closeActive(options),
|
||||
options.cleanupRetry,
|
||||
);
|
||||
return closed.ok
|
||||
? ok(receipt)
|
||||
: err(retainSavedAnalysisSnapshot(closed.error, receipt));
|
||||
});
|
||||
}
|
||||
|
||||
/** Offer the caller's retry only while this session retains the failed cleanup. */
|
||||
#offerCleanupRetry(
|
||||
closed: Result<null, AnalysisError>,
|
||||
retry: AnalysisCleanupRetry | undefined,
|
||||
): Result<null, AnalysisError> {
|
||||
if (
|
||||
!closed.ok &&
|
||||
retry !== undefined &&
|
||||
closed.error.cleanupIncomplete &&
|
||||
this.#retainsCloseCleanup()
|
||||
)
|
||||
closed.error.bindCleanupRetry(retry);
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** Owners the next close retries; external mutation owners retry elsewhere. */
|
||||
#retainsCloseCleanup(): boolean {
|
||||
return (
|
||||
this.#pendingCleanup !== undefined ||
|
||||
this.#pendingBundleResourceCleanup.length > 0 ||
|
||||
this.hasPendingFileResources ||
|
||||
this.#providerRouter.hasPendingOwnedResources()
|
||||
);
|
||||
}
|
||||
|
||||
async #closeActive(
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
@@ -1128,10 +1165,7 @@ export class BinarySession
|
||||
providerOperationHealth: client?.operationHealthSnapshot?.() ?? null,
|
||||
cleanupPending:
|
||||
this.#externalMutationCleanupFailure?.cleanupIncomplete === true ||
|
||||
this.#pendingCleanup !== undefined ||
|
||||
this.#pendingBundleResourceCleanup.length > 0 ||
|
||||
this.hasPendingFileResources ||
|
||||
this.#providerRouter.hasPendingOwnedResources(),
|
||||
this.#retainsCloseCleanup(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"
|
||||
import type { AnalysisProviderSelector } from "../../domain/providerSelection.js";
|
||||
import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { AnalysisCleanupRetry } from "../../domain/analysisErrorRecovery.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { AnalysisSnapshotLoader } from "./BinarySessionOpen.js";
|
||||
@@ -76,16 +77,23 @@ export interface BinarySessionPort
|
||||
path: string,
|
||||
options?: Pick<ExecutionOptions, "signal">,
|
||||
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>>;
|
||||
/**
|
||||
* `cleanupRetry` names the caller's same-session retry; the session binds it
|
||||
* to a failed close only while it retains that cleanup for the next close.
|
||||
*/
|
||||
close(
|
||||
options?: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
readonly cleanupRetry?: AnalysisCleanupRetry;
|
||||
},
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
/** Save a snapshot and close while excluding later provider requests. */
|
||||
closeWithSnapshot(
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
options?: Pick<ExecutionOptions, "progress">,
|
||||
options?: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly cleanupRetry?: AnalysisCleanupRetry;
|
||||
},
|
||||
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>>;
|
||||
/** Default complete detail; summary omits provider diagnostics and candidate capabilities. */
|
||||
status(options?: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
|
||||
import type { AnalysisSettingLimit } from "./analysisSettings.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { AnalysisCleanupRetry } from "./analysisErrorRecovery.js";
|
||||
|
||||
/** Stable tags exposed by safe analysis-error projections. */
|
||||
const ANALYSIS_ERROR_TAGS = [
|
||||
@@ -92,6 +93,7 @@ export abstract class AnalysisError extends Error {
|
||||
#ownedCleanupResources: readonly string[];
|
||||
#cleanupResourceOwner: AnalysisCleanupResourceOwner | undefined;
|
||||
#cleanupCompletionOwner: AnalysisCleanupResourceOwner | undefined;
|
||||
#cleanupRetry: AnalysisCleanupRetry | undefined;
|
||||
#secondaryFailures: AnalysisError[] = [];
|
||||
constructor(message: string, options?: AnalysisErrorOptions) {
|
||||
super(message, options);
|
||||
@@ -164,6 +166,17 @@ export abstract class AnalysisError extends Error {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Caller retry for the live cleanup; undefined once cleanup completes. */
|
||||
get cleanupRetry(): AnalysisCleanupRetry | undefined {
|
||||
return this.cleanupIncomplete ? this.#cleanupRetry : undefined;
|
||||
}
|
||||
|
||||
/** Bind only from the owner retaining every reported resource for this retry. */
|
||||
bindCleanupRetry(retry: AnalysisCleanupRetry): this {
|
||||
this.#cleanupRetry = Object.freeze({ ...retry });
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Initialize resource facts supplied by a more specific error producer. */
|
||||
protected setCleanupResources(resources: readonly string[]): void {
|
||||
this.#ownedCleanupResources = [...resources];
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
projectAnalysisErrorWithoutCapturedOutput,
|
||||
} from "./analysisErrorProjection.js";
|
||||
import type { AnalysisError } from "./analysisErrorBase.js";
|
||||
import { analysisErrorRemediationAction } from "./analysisErrorPresentation.js";
|
||||
|
||||
/** Preserve the original lifecycle failure alongside incomplete cleanup. */
|
||||
export const analysisErrorWithCleanupFailure = (
|
||||
@@ -23,7 +24,8 @@ export const analysisErrorWithCleanupFailure = (
|
||||
{ cause: primary },
|
||||
),
|
||||
userMessage: projectAnalysisError(primary).message,
|
||||
remediationAction: projected.remediation.action,
|
||||
// A retry offer belongs to the owner of the composed resources, if any.
|
||||
remediationAction: analysisErrorRemediationAction(cleanup),
|
||||
...(primary.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: primary.capturedOutput }),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { projectArtifactIntegrityDetails } from "./artifactIntegrity.js";
|
||||
import type { AnalysisErrorRecovery } from "./analysisErrorRecovery.js";
|
||||
import {
|
||||
cleanupRetryRecovery,
|
||||
cleanupRetryRemediation,
|
||||
type AnalysisErrorRecovery,
|
||||
} from "./analysisErrorRecovery.js";
|
||||
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
|
||||
import { isImmutableJsonSnapshot } from "./immutableJson.js";
|
||||
|
||||
@@ -133,7 +137,13 @@ const projectError = (
|
||||
: jsonValueSchema.parse(partialObservation),
|
||||
}),
|
||||
};
|
||||
const recovery = errorRecovery(error);
|
||||
// The retained cleanup owner's retry precedes the failed operation's hint.
|
||||
const cleanupRetry = error.cleanupRetry;
|
||||
const recovery =
|
||||
cleanupRetry === undefined
|
||||
? errorRecovery(error)
|
||||
: cleanupRetryRecovery(cleanupRetry);
|
||||
const remediation = analysisErrorRemediationAction(error);
|
||||
return {
|
||||
code,
|
||||
category: analysisErrorCategory(error),
|
||||
@@ -146,7 +156,10 @@ const projectError = (
|
||||
error instanceof AnalysisResourceConstraintError &&
|
||||
(error.retryable ?? error.resource === "capacity")),
|
||||
remediation: {
|
||||
action: analysisErrorRemediationAction(error),
|
||||
action:
|
||||
cleanupRetry === undefined
|
||||
? remediation
|
||||
: `${remediation} ${cleanupRetryRemediation(cleanupRetry)}`,
|
||||
},
|
||||
...(recovery === undefined ? {} : { recovery }),
|
||||
...(Object.keys(details).length === 0 ? {} : { details }),
|
||||
|
||||
@@ -16,8 +16,32 @@ export type AnalysisErrorRecovery =
|
||||
readonly kind: "inspect_timeout_state";
|
||||
readonly operation: string;
|
||||
readonly timeout_ms: number;
|
||||
}
|
||||
| {
|
||||
/** `operation` retries only retained cleanup on the same connection. */
|
||||
readonly kind: "retry_cleanup";
|
||||
readonly operation: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same-connection operation that retries every live cleanup resource an error
|
||||
* reports. Only the owner retaining those resources for that retry binds it.
|
||||
*/
|
||||
export interface AnalysisCleanupRetry {
|
||||
readonly operation: "close_binary";
|
||||
}
|
||||
|
||||
export const cleanupRetryRecovery = (
|
||||
retry: AnalysisCleanupRetry,
|
||||
): AnalysisErrorRecovery => ({
|
||||
kind: "retry_cleanup",
|
||||
operation: retry.operation,
|
||||
});
|
||||
|
||||
/** Retry advice is conditional; it never promises release or process exit. */
|
||||
export const cleanupRetryRemediation = (retry: AnalysisCleanupRetry): string =>
|
||||
`This connection retains that cleanup. Keep it open; after the reported condition settles or is resolved, call ${retry.operation} without snapshot_path and check its result. That call retries the retained cleanup without rerunning the failed analysis, clears session records like any ${retry.operation}, and can report cleanup_incomplete again.`;
|
||||
|
||||
export const analysisErrorRecoverySchema: z.ZodType<AnalysisErrorRecovery> =
|
||||
z.discriminatedUnion("kind", [
|
||||
z
|
||||
@@ -33,4 +57,10 @@ export const analysisErrorRecoverySchema: z.ZodType<AnalysisErrorRecovery> =
|
||||
timeout_ms: z.number().int().positive(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("retry_cleanup"),
|
||||
operation: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
@@ -2,6 +2,14 @@ import { expect, it } from "vitest";
|
||||
import { ProviderCleanupError } from "./providerCleanupError.js";
|
||||
import { projectAnalysisError } from "./analysisErrorProjection.js";
|
||||
import { analysisErrorWithCleanupFailure } from "./analysisErrorCleanup.js";
|
||||
import { AnalysisCancelledError } from "./analysisErrorCore.js";
|
||||
import { cleanupRetryRemediation } from "./analysisErrorRecovery.js";
|
||||
import { analysisErrorRemediationAction } from "./analysisErrorPresentation.js";
|
||||
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
|
||||
|
||||
const CLOSE_RETRY = { operation: "close_binary" } as const;
|
||||
// Guidance comes from its owning formatters; tests check composition, not prose.
|
||||
const RETRY_ACTION = cleanupRetryRemediation(CLOSE_RETRY);
|
||||
it("preserves the binary cleanup default and an explicitly owned nonbinary operation", () => {
|
||||
expect(new ProviderCleanupError("ghidra", ["database"], {}).operation).toBe(
|
||||
"close_binary",
|
||||
@@ -96,3 +104,73 @@ it.each(["alpha", "beta"])(
|
||||
expect(projectAnalysisError(outer).code).not.toBe("cleanup_incomplete");
|
||||
},
|
||||
);
|
||||
|
||||
it("offers a bound cleanup retry only while that cleanup remains incomplete", () => {
|
||||
const plain = new ProviderCleanupError("kuna", ["staging"], {
|
||||
reason: "stop unproven",
|
||||
});
|
||||
const unbound = projectAnalysisError(plain);
|
||||
expect(unbound).not.toHaveProperty("recovery");
|
||||
expect(unbound.remediation.action).toBe(
|
||||
analysisErrorRemediationAction(plain),
|
||||
);
|
||||
expect(unbound.remediation.action).not.toContain(RETRY_ACTION);
|
||||
|
||||
const retained = new ProviderCleanupError("kuna", ["staging"], {
|
||||
reason: "stop unproven",
|
||||
}).bindCleanupRetry(CLOSE_RETRY);
|
||||
const offered = projectAnalysisError(retained);
|
||||
expect(offered).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
retryable: false,
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
details: { resources: ["staging"] },
|
||||
});
|
||||
expect(offered.remediation.action).toBe(
|
||||
`${analysisErrorRemediationAction(retained)} ${RETRY_ACTION}`,
|
||||
);
|
||||
expect(analysisErrorProjectionSchema.parse(offered)).toEqual(offered);
|
||||
|
||||
retained.resolveCleanup();
|
||||
expect(retained.cleanupRetry).toBeUndefined();
|
||||
const released = projectAnalysisError(retained);
|
||||
expect(released.code).toBe("execution_failure");
|
||||
expect(released).not.toHaveProperty("recovery");
|
||||
expect(released.remediation.action).not.toContain(RETRY_ACTION);
|
||||
});
|
||||
|
||||
it("keeps a retry offer with the owner of the composed resources", () => {
|
||||
const primary = new AnalysisCancelledError("procedure_assembly");
|
||||
const retained = new ProviderCleanupError("kuna", ["staging"], {
|
||||
reason: "stop unproven",
|
||||
}).bindCleanupRetry(CLOSE_RETRY);
|
||||
const composed = analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
retained,
|
||||
).bindCleanupDependencies([primary, retained]);
|
||||
// A peer's offer cannot cover resources the composition adds.
|
||||
const unbound = projectAnalysisError(composed);
|
||||
expect(unbound).not.toHaveProperty("recovery");
|
||||
// The composition keeps the cleanup owner's remediation, not its retry offer.
|
||||
expect(unbound.remediation.action).toBe(
|
||||
analysisErrorRemediationAction(retained),
|
||||
);
|
||||
expect(unbound.remediation.action).not.toContain(RETRY_ACTION);
|
||||
|
||||
composed.bindCleanupRetry(CLOSE_RETRY);
|
||||
const offered = projectAnalysisError(composed);
|
||||
expect(offered).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
details: { execution_failure: "cancelled" },
|
||||
});
|
||||
expect(offered.remediation.action).toBe(
|
||||
`${analysisErrorRemediationAction(retained)} ${RETRY_ACTION}`,
|
||||
);
|
||||
expect(offered.remediation.action.split(RETRY_ACTION)).toHaveLength(2);
|
||||
|
||||
retained.resolveCleanup();
|
||||
expect(composed.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(composed)).toMatchObject({ code: "cancelled" });
|
||||
expect(projectAnalysisError(composed)).not.toHaveProperty("recovery");
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import type { AnalysisCleanupRetry } from "../domain/analysisErrorRecovery.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { mcpProgressReporter } from "./mcpProgress.js";
|
||||
import type { LifecycleToolRegistration } from "./registerSessionTools.js";
|
||||
@@ -27,6 +28,13 @@ const closeResult = async <Value>(
|
||||
}
|
||||
};
|
||||
|
||||
/** This tool retries the session and process-capture cleanup it retains. */
|
||||
const CLOSE_RETRY: AnalysisCleanupRetry = { operation: "close_binary" };
|
||||
|
||||
/** The process-capture scope keeps failed leases for the next close_binary. */
|
||||
const retainedProcessCleanup = (error: AnalysisError): AnalysisError =>
|
||||
error.cleanupIncomplete ? error.bindCleanupRetry(CLOSE_RETRY) : error;
|
||||
|
||||
/** Register provider cleanup and optional pre-close snapshot persistence. */
|
||||
export const registerCloseLifecycleTool = ({
|
||||
server,
|
||||
@@ -65,24 +73,26 @@ export const registerCloseLifecycleTool = ({
|
||||
const closed = sessionClose.value;
|
||||
const resources = resourceClose.value;
|
||||
if (!closed.ok) {
|
||||
if (!resources.ok)
|
||||
return err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
closed.error,
|
||||
resources.error,
|
||||
).bindCleanupDependencies([closed.error, resources.error]),
|
||||
);
|
||||
return closed;
|
||||
if (resources.ok) return closed;
|
||||
const combined = analysisErrorWithCleanupFailure(
|
||||
closed.error,
|
||||
resources.error,
|
||||
).bindCleanupDependencies([closed.error, resources.error]);
|
||||
// The session offers a retry only after retiring its target; a failed
|
||||
// save keeps that target open and needs its own recovery first.
|
||||
return err(
|
||||
closed.error.cleanupRetry === undefined
|
||||
? combined
|
||||
: combined.bindCleanupRetry(CLOSE_RETRY),
|
||||
);
|
||||
}
|
||||
if (resources.ok) return closed;
|
||||
return savedSnapshot === undefined
|
||||
? resources
|
||||
: err(
|
||||
retainSavedAnalysisSnapshot(
|
||||
resources.error,
|
||||
savedSnapshot(closed.value),
|
||||
),
|
||||
);
|
||||
const retained = retainedProcessCleanup(resources.error);
|
||||
return err(
|
||||
savedSnapshot === undefined
|
||||
? retained
|
||||
: retainSavedAnalysisSnapshot(retained, savedSnapshot(closed.value)),
|
||||
);
|
||||
};
|
||||
server.registerTool(
|
||||
closeContract.name,
|
||||
@@ -94,7 +104,9 @@ export const registerCloseLifecycleTool = ({
|
||||
// Enqueue the lifecycle transition before awaiting progress transport so
|
||||
// a later open request cannot overtake this close.
|
||||
const closing = logToolExecution(logger, closeContract.name, () =>
|
||||
closeWithResources(() => session.close({ progress })),
|
||||
closeWithResources(() =>
|
||||
session.close({ progress, cleanupRetry: CLOSE_RETRY }),
|
||||
),
|
||||
);
|
||||
await reportOperationStart(progress, closeContract.name);
|
||||
const closed = await closing;
|
||||
@@ -106,6 +118,7 @@ export const registerCloseLifecycleTool = ({
|
||||
() =>
|
||||
session.closeWithSnapshot(snapshotPath, input.overwrite, {
|
||||
progress,
|
||||
cleanupRetry: CLOSE_RETRY,
|
||||
}),
|
||||
(receipt) => receipt,
|
||||
),
|
||||
|
||||
@@ -96,6 +96,8 @@ describe("headless IDA timeout through the one-shot CLI", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
// The one-shot CLI exits after its close, so no close_binary retry exists.
|
||||
expect(JSON.stringify(result.json)).not.toContain("retry_cleanup");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it as test, vi } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js";
|
||||
import { cleanupRetryRemediation } from "../../../src/domain/analysisErrorRecovery.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { ToolResultDelivery } from "../../../src/server/ToolResultDelivery.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import {
|
||||
exportsOf,
|
||||
session as kunaSession,
|
||||
unprovenCleanupLauncher,
|
||||
} from "../providers/kuna/kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
/** Minimal ELF64 x86-64 header; the fake Kuna never parses the program. */
|
||||
const elfProgram = (): Buffer => {
|
||||
const header = Buffer.alloc(64);
|
||||
header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1], 0);
|
||||
header.writeUInt16LE(3, 16);
|
||||
header.writeUInt16LE(0x3e, 18);
|
||||
header.writeUInt32LE(1, 20);
|
||||
header.writeUInt16LE(header.length, 52);
|
||||
return header;
|
||||
};
|
||||
|
||||
const sessionStatus = async (client: Client) =>
|
||||
toolContract("binary_session").outputSchema.parse(
|
||||
(await client.callTool({ name: "binary_session", arguments: {} }))
|
||||
.structuredContent,
|
||||
).result;
|
||||
|
||||
it("names the same-connection close_binary retry for an unproven Kuna export stop", async () => {
|
||||
const unproven = unprovenCleanupLauncher("decompile-project");
|
||||
const fixture = await kunaSession(
|
||||
"rea-kuna-close-retry-mcp-",
|
||||
{ project: "hang" },
|
||||
{},
|
||||
unproven.launcher,
|
||||
);
|
||||
const session = createTestBinarySession(fixture.provider);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ logger: silentLogger },
|
||||
);
|
||||
const client = new Client({ name: "kuna-close-retry", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const call = (name: string, arguments_: Record<string, unknown> = {}) =>
|
||||
client.callTool({ name, arguments: arguments_ });
|
||||
const program = join(fixture.root, "program.elf");
|
||||
await writeFile(program, elfProgram());
|
||||
const targetBytes = await readFile(program);
|
||||
const delivery = vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult");
|
||||
try {
|
||||
const opened = await call("open_binary", { path: program });
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
// The single-function answer returns while the project export stays live.
|
||||
const text = await call("procedure_pseudo_code", { procedure: "main" });
|
||||
expect(text.isError, JSON.stringify(text.content)).not.toBe(true);
|
||||
await expect.poll(() => exportsOf(fixture.kuna)).toHaveLength(1);
|
||||
|
||||
const failed = parseMcpToolError(await call("close_binary"));
|
||||
const delivered = delivery.mock.calls.at(-1)?.[0];
|
||||
if (delivered === undefined || delivered.ok)
|
||||
throw new Error("Expected the typed close failure at delivery");
|
||||
expect(failed.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
retryable: false,
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
details: {
|
||||
provider_id: "kuna",
|
||||
operation: "close_binary",
|
||||
cleanup: "incomplete",
|
||||
diagnostics: { reason: "decompile-project stop unproven" },
|
||||
},
|
||||
});
|
||||
// The owner's guidance stays first; the retry offer is appended once.
|
||||
expect(failed.error.remediation.action).toBe(
|
||||
`${analysisErrorRemediationAction(delivered.error)} ${cleanupRetryRemediation({ operation: "close_binary" })}`,
|
||||
);
|
||||
expect(await sessionStatus(client)).toMatchObject({
|
||||
open: false,
|
||||
cleanup_pending: true,
|
||||
});
|
||||
// Reopening stays blocked; only close_binary offers its retry.
|
||||
const blocked = parseMcpToolError(
|
||||
await call("open_binary", { path: program }),
|
||||
);
|
||||
expect(blocked.error.code).toBe("cleanup_incomplete");
|
||||
expect(blocked.error).not.toHaveProperty("recovery");
|
||||
|
||||
// A retry that still cannot prove the stop keeps the same conditional offer.
|
||||
const repeated = parseMcpToolError(await call("close_binary"));
|
||||
expect(repeated.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
});
|
||||
|
||||
unproven.release();
|
||||
const recovered = await call("close_binary");
|
||||
expect(recovered.isError, JSON.stringify(recovered.content)).not.toBe(true);
|
||||
expect(await sessionStatus(client)).toMatchObject({
|
||||
open: false,
|
||||
cleanup_pending: false,
|
||||
});
|
||||
expect(await readdir(join(fixture.cacheRoot, "staging"))).toEqual([]);
|
||||
expect(Buffer.compare(await readFile(program), targetBytes)).toBe(0);
|
||||
} finally {
|
||||
delivery.mockRestore();
|
||||
unproven.release();
|
||||
await client.close();
|
||||
await server.close();
|
||||
const closed = await session.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
});
|
||||
@@ -121,6 +121,7 @@ it("projects the retained process root through close_binary and resolves its ret
|
||||
const firstWire = JSON.stringify(failed);
|
||||
expect(failed.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
details: { resources: [root] },
|
||||
});
|
||||
expect(fault.removalAttempts).toBe(2);
|
||||
@@ -182,6 +183,9 @@ it.each(["returned", "thrown", "synchronous"] as const)(
|
||||
if (delivered === undefined || delivered.ok)
|
||||
throw new Error("Expected the composed lifecycle error at delivery");
|
||||
const composed = delivered.error;
|
||||
// The session failure did not retire its target under a retained
|
||||
// cleanup, so its own recovery applies before any close retry.
|
||||
expect(failed.error).not.toHaveProperty("recovery");
|
||||
expect(failed.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
} from "../../../src/artifacts/AnalysisCleanupLease.js";
|
||||
import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import { HopperStartError } from "../../../src/domain/hopperErrors.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
@@ -14,11 +17,14 @@ import { err, ok as resultOk } from "../../../src/domain/result.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
import {
|
||||
createBinarySessionTargets,
|
||||
createCacheProvider,
|
||||
createDeferred,
|
||||
createTestBinarySession,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const CLOSE_RETRY = { operation: "close_binary" } as const;
|
||||
|
||||
const retryFixture = async (
|
||||
execute: AnalysisClient["execute"] = () => Promise.resolve(ok(null)),
|
||||
onClose?: () => void,
|
||||
@@ -168,6 +174,106 @@ it.each(["close", "switch", "open after close"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it("offers a caller's close retry only while the session retains its cleanup", async () => {
|
||||
const fixture = await retryFixture();
|
||||
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
|
||||
// One-shot callers name no retry and therefore receive none.
|
||||
const oneShot = await fixture.session.close();
|
||||
if (oneShot.ok) throw new Error("Expected retained cleanup");
|
||||
expect(projectAnalysisError(oneShot.error)).not.toHaveProperty("recovery");
|
||||
|
||||
const offered = await fixture.session.close({ cleanupRetry: CLOSE_RETRY });
|
||||
if (offered.ok) throw new Error("Expected retained cleanup");
|
||||
expect(projectAnalysisError(offered.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
details: { resources: [fixture.runtime] },
|
||||
});
|
||||
|
||||
fixture.allowCleanup();
|
||||
expect((await fixture.session.close({ cleanupRetry: CLOSE_RETRY })).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(projectAnalysisError(fixture.cleanupError)).not.toHaveProperty(
|
||||
"recovery",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a close retry after a saved snapshot but not after a failed save", async () => {
|
||||
const [first] = await createBinarySessionTargets();
|
||||
const parent = await createTestTempDirectory("rea-snapshot-close-retry-");
|
||||
const failedSave = join(parent, "failed-save.json");
|
||||
const savedPath = join(parent, "saved.json");
|
||||
const cache = createCacheProvider([]);
|
||||
let allowed = false;
|
||||
const cleanupError = new ProviderCleanupError("fixture", ["runtime"], {
|
||||
reason: "runtime removal denied",
|
||||
});
|
||||
const provider: AnalysisProvider = {
|
||||
...cache,
|
||||
createClient: (...arguments_) => ({
|
||||
...cache.createClient(...arguments_),
|
||||
close: async () => (allowed ? resultOk(null) : err(cleanupError)),
|
||||
}),
|
||||
};
|
||||
const session = createTestBinarySession(provider);
|
||||
onTestFinished(async () => {
|
||||
allowed = true;
|
||||
await session.close();
|
||||
});
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
// The composite route creates the provider client on first use.
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
|
||||
// A retained session file owner shows the offer does not follow pending
|
||||
// cleanup alone: the failed save keeps the target open.
|
||||
let releaseFile = false;
|
||||
session.resourceScope.retain(
|
||||
{
|
||||
close: async () => {
|
||||
if (!releaseFile) throw new Error("retained descriptor close denied");
|
||||
},
|
||||
cleanupResources: [failedSave],
|
||||
},
|
||||
failedSave,
|
||||
);
|
||||
const saveFailure = new ProviderCleanupError("rea", [failedSave], {
|
||||
reason: "snapshot descriptor close denied",
|
||||
});
|
||||
const write = vi
|
||||
.spyOn(session, "writeAnalysisSnapshot")
|
||||
.mockResolvedValueOnce(err(saveFailure));
|
||||
onTestFinished(() => write.mockRestore());
|
||||
expect(
|
||||
await session.closeWithSnapshot(failedSave, false, {
|
||||
cleanupRetry: CLOSE_RETRY,
|
||||
}),
|
||||
).toEqual(err(saveFailure));
|
||||
expect(projectAnalysisError(saveFailure)).not.toHaveProperty("recovery");
|
||||
expect(await session.status()).toMatchObject({
|
||||
open: true,
|
||||
cleanup_pending: true,
|
||||
});
|
||||
|
||||
releaseFile = true;
|
||||
const closed = await session.closeWithSnapshot(savedPath, false, {
|
||||
cleanupRetry: CLOSE_RETRY,
|
||||
});
|
||||
if (closed.ok) throw new Error("Expected retained provider cleanup");
|
||||
expect(projectAnalysisError(closed.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
details: {
|
||||
resources: ["runtime"],
|
||||
partial_observation: { saved_snapshot: { bytes: expect.any(Number) } },
|
||||
},
|
||||
});
|
||||
await access(savedPath);
|
||||
allowed = true;
|
||||
expect((await session.close({ cleanupRetry: CLOSE_RETRY })).ok).toBe(true);
|
||||
expect(await session.status()).toMatchObject({ cleanup_pending: false });
|
||||
});
|
||||
|
||||
it("keeps a failed candidate for cleanup and preserves its startup failure", async () => {
|
||||
const primary = new HopperStartError();
|
||||
const fixture = await retryFixture(() => Promise.resolve(err(primary)));
|
||||
|
||||
Reference in new issue
Block a user