@zq-silk/yui 0.15.12 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +3 -2
- package/ARCHITECTURE.zh-CN.md +3 -2
- package/dist/artifacts/artifactCommitLock.js +16 -55
- package/dist/artifacts/taskArtifactRepository.js +3 -3
- package/dist/cli/agentConfigurationPicker.js +1 -1
- package/dist/cli/commandCatalog.js +57 -41
- package/dist/cli/completion.js +18 -18
- package/dist/cli/completionWizard.js +1 -1
- package/dist/cli/dynamicCompletion.js +1 -1
- package/dist/cli/interactionPolicy.js +7 -3
- package/dist/cli/invocationAuthority.js +64 -0
- package/dist/cli/managedDiagnostics.js +1 -1
- package/dist/cli/parseRepeatable.js +35 -0
- package/dist/cli/updatePorts.js +26 -5
- package/dist/cli.js +1280 -1243
- package/dist/commands/agentCommands.js +4 -4
- package/dist/commands/capabilityCommands.js +1 -1
- package/dist/commands/configCommands.js +6 -57
- package/dist/commands/configOverview.js +2 -2
- package/dist/commands/durableJobCommands.js +12 -6
- package/dist/commands/globalRoleCommands.js +53 -29
- package/dist/commands/grantCommands.js +0 -3
- package/dist/commands/jobCommands.js +1 -4
- package/dist/commands/operatorCommands.js +3 -3
- package/dist/commands/projectCommands.js +1 -10
- package/dist/commands/resourcesCommands.js +10 -40
- package/dist/commands/roleConfiguration.js +2 -6
- package/dist/commands/taskActivationCommands.js +4 -8
- package/dist/commands/taskAgentCapabilities.js +9 -0
- package/dist/commands/taskCommandSupport.js +155 -0
- package/dist/commands/taskCommandTypes.js +1 -0
- package/dist/commands/taskCommands.js +271 -710
- package/dist/commands/taskCompletionGate.js +1 -1
- package/dist/commands/taskExecutionCommands.js +1 -1
- package/dist/commands/taskFactCommands.js +325 -0
- package/dist/commands/taskInputCommands.js +12 -42
- package/dist/commands/taskIntegrationCommands.js +17 -57
- package/dist/commands/taskNextActionCommand.js +7 -6
- package/dist/commands/taskPublicationAdoptCommand.js +3 -3
- package/dist/commands/taskPublicationCommands.js +1 -1
- package/dist/commands/taskPublicationVerifyCommand.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +2 -94
- package/dist/commands/taskUpstreamCommands.js +19 -8
- package/dist/commands/workflowCommands.js +6 -1
- package/dist/completion/completionInstaller.js +26 -26
- package/dist/completion/completionState.js +26 -26
- package/dist/completion/fileCompletionManager.js +6 -11
- package/dist/config/configCatalog.js +0 -2
- package/dist/config/timeZone.js +14 -0
- package/dist/config/yuiConfig.js +0 -29
- package/dist/context/runContextPack.js +2 -3
- package/dist/context/taskCatalog.js +3 -5
- package/dist/context/taskContext.js +139 -151
- package/dist/context/wakeRunReferences.js +11 -0
- package/dist/controller/agentCapabilities.js +58 -0
- package/dist/controller/agentRuntimeObserver.js +6 -11
- package/dist/controller/clientRuntime.js +22 -14
- package/dist/controller/controller.js +9 -22
- package/dist/controller/fileSchedulerStoreAdapter.js +117 -212
- package/dist/controller/jobClient.js +4 -4
- package/dist/controller/jobControl.js +60 -25
- package/dist/controller/jobSupervisor.js +4 -4
- package/dist/controller/providerRetryDelivery.js +7 -7
- package/dist/controller/runtime.js +51 -44
- package/dist/controller/runtimeEventProcessor.js +0 -5
- package/dist/controller/sessionOwnerReconciliation.js +5 -0
- package/dist/controller/taskAgentError.js +35 -0
- package/dist/coordination/workMailboxQueue.js +2 -24
- package/dist/core/controllerClient.js +2 -2
- package/dist/core/fileLockOwner.js +74 -0
- package/dist/decision/decision.js +17 -0
- package/dist/doctor/doctor.js +18 -9
- package/dist/event/taskEvent.js +12 -0
- package/dist/execution/workItemExecutionProjection.js +2 -2
- package/dist/executor/agentCapabilityConfig.js +43 -0
- package/dist/executor/agentConfigurationCatalog.js +49 -11
- package/dist/executor/agentExecutor.js +5 -8
- package/dist/executor/fileRoleLaunchPlanner.js +11 -53
- package/dist/executor/taskAgentCapabilities.js +64 -0
- package/dist/integration/deliveryObligation.js +1 -69
- package/dist/integration/gitIntegrationService.js +60 -84
- package/dist/integration/integrationAttempt.js +3 -14
- package/dist/integration/integrationSourceApplication.js +4 -12
- package/dist/integration/manifestTags.js +2 -2
- package/dist/job/durableJob.js +4 -25
- package/dist/job/jobAssignmentScope.js +22 -0
- package/dist/job/jobOperation.js +16 -0
- package/dist/job/jobRunner.js +2 -1
- package/dist/job/stepDirectory.js +15 -0
- package/dist/kernel/builtinCapabilities.js +15 -11
- package/dist/kernel/kernelPorts.js +1 -17
- package/dist/lifecycle/exactRunTerminalization.js +1 -9
- package/dist/message/inputControlResolution.js +3 -4
- package/dist/message/message.js +17 -20
- package/dist/message/messageContinuation.js +1 -1
- package/dist/milestone/milestone.js +11 -0
- package/dist/observability/orchestrationMetrics.js +24 -1
- package/dist/observability/runtimeIdentity.js +1 -17
- package/dist/output/agentConfigurationPresentation.js +2 -0
- package/dist/output/timePresentation.js +1 -14
- package/dist/plugins/pluginService.js +12 -4
- package/dist/release/releaseWorkflowPorts.js +45 -96
- package/dist/release/runtimeRelease.js +9 -15
- package/dist/repository/projectMaintenanceLock.js +23 -64
- package/dist/repository/taskBaseFreshness.js +1 -1
- package/dist/repository/taskWorkspaceCoordinator.js +13 -22
- package/dist/repository/taskWorkspacePreparer.js +29 -25
- package/dist/repository/workspaceCleanupInspection.js +5 -5
- package/dist/resources/autoResourceGc.js +4 -77
- package/dist/resources/liveReferences.js +7 -54
- package/dist/resources/resourceDiscovery.js +5 -118
- package/dist/resources/resourceGc.js +233 -181
- package/dist/resources/resourceRegistrar.js +5 -4
- package/dist/resources/resourceRegistry.js +3 -37
- package/dist/resources/resourceRegistryStore.js +13 -36
- package/dist/resources/sqliteResourceRegistry.js +43 -26
- package/dist/review/deltaRecheck.js +1 -1
- package/dist/review/reviewAcceptance.js +1 -1
- package/dist/review/reviewDecision.js +1 -1
- package/dist/review/reviewRound.js +8 -7
- package/dist/review/taskFinalReviewContractResolution.js +1 -1
- package/dist/runtime/acpProtocol.js +4 -51
- package/dist/runtime/acpSession.js +10 -75
- package/dist/runtime/acpSessionConfiguration.js +1 -8
- package/dist/runtime/agentEndpoint.js +3 -2
- package/dist/runtime/agentFailureContext.js +43 -0
- package/dist/runtime/agentHost.js +15 -15
- package/dist/runtime/codexInteractiveHost.js +2 -2
- package/dist/runtime/index.js +0 -1
- package/dist/runtime/managedCaller.js +21 -1
- package/dist/runtime/providerErrors.js +38 -0
- package/dist/runtime/runtimeObservation.js +0 -5
- package/dist/runtime/runtimeSessionCandidate.js +2 -3
- package/dist/runtime/sessionOwnerIdentity.js +1 -1
- package/dist/runtime/structuredProviderHost.js +4 -41
- package/dist/runtime/tmuxAdapters.js +6 -8
- package/dist/scheduler/activeRoleRunDelivery.js +7 -7
- package/dist/scheduler/leaderWakeupProcessor.js +19 -6
- package/dist/scheduler/operatorEvent.js +12 -20
- package/dist/scheduler/operatorInputNotificationProcessor.js +1 -1
- package/dist/scheduler/ports.js +5 -8
- package/dist/scheduler/roleRunLiveness.js +4 -4
- package/dist/scheduler/roleRunStall.js +16 -23
- package/dist/scheduler/taskExecutionProjection.js +25 -33
- package/dist/scheduler/taskObservabilityProjection.js +0 -1
- package/dist/scheduler/taskWake.js +5 -10
- package/dist/scheduler/wakeReason.js +2 -0
- package/dist/scheduler/wakeupQueue.js +2 -4
- package/dist/setup/setupCommand.js +1 -1
- package/dist/storage/contextRecords.js +106 -0
- package/dist/storage/currentTaskStore.js +1 -1
- package/dist/storage/homeLayout.js +13 -17
- package/dist/storage/migrations/agentFailureContext.js +22 -0
- package/dist/storage/migrations/currentInputContract.js +86 -0
- package/dist/storage/migrations/currentRuntimeContract.js +228 -0
- package/dist/storage/migrations/historicalVerificationPlan.js +35 -0
- package/dist/storage/migrations/integrationContinuation.js +5 -4
- package/dist/storage/migrations/narrowAgentFailureContext.js +65 -0
- package/dist/storage/migrations/notificationOnlyWakes.js +74 -0
- package/dist/storage/migrations/verificationPlanV1.js +162 -0
- package/dist/storage/migrations/verificationPolicy.js +74 -0
- package/dist/storage/migrations/workItemHistory.js +46 -0
- package/dist/storage/persistenceWorker.js +12 -4
- package/dist/storage/recordValidation.js +87 -0
- package/dist/storage/sqliteSchema.js +142 -1
- package/dist/storage/sqliteStore.js +156 -162
- package/dist/storage/storageSchema.js +2 -14
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +15 -7
- package/dist/storage/taskStore.js +3 -7
- package/dist/storage/upgrade/upgradeOrchestrator.js +25 -37
- package/dist/task/completionReadiness.js +5 -24
- package/dist/task/draftPlan.js +0 -53
- package/dist/task/nextAction.js +7 -13
- package/dist/task/remoteDeliveryService.js +89 -0
- package/dist/task/taskActivation.js +2 -25
- package/dist/task/taskActivationService.js +0 -2
- package/dist/task/taskRecordReference.js +0 -1
- package/dist/task/taskSubmission.js +3 -14
- package/dist/telemetry/sqliteTelemetryBatch.js +29 -0
- package/dist/telemetry/sqliteTelemetryStore.js +49 -49
- package/dist/telemetry/telemetryWiring.js +4 -3
- package/dist/tmux/tmuxManager.js +29 -20
- package/dist/verification/gateArtifact.js +15 -24
- package/dist/verification/gateArtifactStore.js +14 -7
- package/dist/verification/verificationGateService.js +29 -88
- package/dist/verification/verificationPlan.js +27 -72
- package/dist/web/assets/client/app.js +1 -1
- package/dist/web/assets/client/taskSurface.js +4 -4
- package/dist/web/webServer.js +4 -6
- package/dist/web/webSnapshot.js +3 -83
- package/dist/web/webTaskSurface.js +17 -46
- package/dist/workItem/workItem.js +1 -12
- package/docs/architecture/capabilities-and-resources.md +14 -2
- package/docs/architecture/capabilities-and-resources.zh-CN.md +11 -1
- package/docs/managed-turn-and-session-runtime.md +83 -10
- package/docs/managed-turn-and-session-runtime.zh-CN.md +65 -11
- package/docs/plugin-sdk.md +4 -2
- package/docs/provider-runtime.md +3 -1
- package/docs/provider-runtime.zh-CN.md +4 -2
- package/docs/release-workflow.md +190 -7
- package/docs/release-workflow.zh-CN.md +139 -5
- package/docs/roles-and-configuration.md +29 -0
- package/docs/roles-and-configuration.zh-CN.md +21 -0
- package/docs/sqlite-control-plane-design.md +16 -3
- package/docs/sqlite-control-plane-design.zh-CN.md +13 -2
- package/docs/task-dag-semantics.md +3 -3
- package/docs/task-dag-semantics.zh-CN.md +2 -2
- package/docs/task-delivery.md +91 -5
- package/docs/task-delivery.zh-CN.md +72 -4
- package/docs/task-discovery.md +10 -11
- package/docs/task-discovery.zh-CN.md +8 -9
- package/docs/testing/verification-levels.md +145 -9
- package/docs/testing/verification-levels.zh-CN.md +103 -8
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +5 -0
- package/skills/yui-leader/references/integration.md +9 -5
- package/skills/yui-leader/references/planning.md +9 -3
- package/skills/yui-operator/SKILL.md +15 -10
- package/skills/yui-runtime/SKILL.md +6 -6
- package/skills/yui-runtime/references/recovery.md +47 -0
- package/dist/agent/agentRegistry.js +0 -10
- package/dist/agentRun/runIdentity.js +0 -22
- package/dist/commands/deliveryGuardPreflight.js +0 -30
- package/dist/commands/taskIntegrationQueueCommands.js +0 -226
- package/dist/commands/taskOverviewCommand.js +0 -377
- package/dist/completion/completionWizard.js +0 -125
- package/dist/context/dispatchContext.js +0 -110
- package/dist/context/wakeNotification.js +0 -144
- package/dist/integration/integrationCheckEvidenceReuse.js +0 -53
- package/dist/integration/integrationQueueEntry.js +0 -191
- package/dist/integration/integrationQueueService.js +0 -810
- package/dist/release/fakeReleasePorts.js +0 -55
- package/dist/runtime/sessionOwnerRegistry.js +0 -137
- package/dist/task/deliveryGuard.js +0 -226
- /package/dist/{commands/taskActor.js → task/taskAuthority.js} +0 -0
|
@@ -9,24 +9,70 @@
|
|
|
9
9
|
* Safety model:
|
|
10
10
|
* - `planResourceGc` is strictly read-only: it discovers, scans, classifies,
|
|
11
11
|
* and returns a plan. It never writes the registry or moves files.
|
|
12
|
-
* - `applyResourceGc`
|
|
13
|
-
*
|
|
14
|
-
* gained a reference after planning.
|
|
12
|
+
* - `applyResourceGc` refreshes discovery and scans each physical subtree.
|
|
13
|
+
* Durable owners are re-read under SQLite's writer fence through the move.
|
|
15
14
|
* - `purgeResourceQuarantine` scans both the original and quarantine paths;
|
|
16
15
|
* a file held open inside quarantine triggers a restore instead of deletion.
|
|
17
16
|
* - `restoreAllResourceGc` is the explicit rollback entry point.
|
|
18
17
|
*/
|
|
19
|
-
import {
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
20
19
|
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
-
import { dirname, join, resolve } from "node:path";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { createResourceRegistryStore } from "./resourceRegistryStore.js";
|
|
25
|
-
import { isReleasable, isTerminalTaskStatus } from "./resourceTypes.js";
|
|
26
|
-
import { scanLiveReferences } from "./liveReferences.js";
|
|
20
|
+
import { dirname, join, resolve, relative, isAbsolute } from "node:path";
|
|
21
|
+
import { isDeepStrictEqual } from "node:util";
|
|
22
|
+
import { scanLiveReferences, readSessionOwnerClaims } from "./liveReferences.js";
|
|
27
23
|
import { discoverResources } from "./resourceDiscovery.js";
|
|
28
|
-
|
|
24
|
+
import { isResourceQuarantinePath, removeResourceRecord, resourceQuarantineRoot, upsertResourceRecord } from "./resourceRegistry.js";
|
|
25
|
+
import { withResourceRegistry } from "./resourceRegistryStore.js";
|
|
26
|
+
import { isReleasable, isTerminalTaskStatus } from "./resourceTypes.js";
|
|
29
27
|
export const DEFAULT_QUARANTINE_TTL_HOURS = 24;
|
|
28
|
+
/** One current snapshot shared by manual and automatic GC. The execution
|
|
29
|
+
* boundary reads it again while holding the database's existing writer fence. */
|
|
30
|
+
export function readResourceGcState(store) {
|
|
31
|
+
return store.readTransaction(reader => {
|
|
32
|
+
const tasks = reader.listTasks();
|
|
33
|
+
return {
|
|
34
|
+
projects: reader.listProjects(),
|
|
35
|
+
sessionOwners: reader.listSessionOwners(),
|
|
36
|
+
managedWorkspaces: tasks.flatMap(task => reader.listManagedWorkspaces(task.id)),
|
|
37
|
+
taskStatusById: new Map(tasks.map(task => [task.id, task.status])),
|
|
38
|
+
activeWorkspaceOwnerPaths: tasks.flatMap(task => [
|
|
39
|
+
...reader.listRuns(task.id).filter(run => run.status === "active").flatMap(run => {
|
|
40
|
+
const workspace = run.workspace ?? run.effective.workspace;
|
|
41
|
+
return [workspace.root, ...workspace.entries.map(entry => entry.path)];
|
|
42
|
+
}),
|
|
43
|
+
...reader.listDurableJobs(task.id).filter(job => job.status === "queued" || job.status === "running"
|
|
44
|
+
|| (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))
|
|
45
|
+
.map(job => job.workspace)
|
|
46
|
+
])
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function within(path, parent) {
|
|
51
|
+
const child = relative(resolve(parent), resolve(path));
|
|
52
|
+
return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith("../"));
|
|
53
|
+
}
|
|
54
|
+
function currentDurableRefs(record, state) {
|
|
55
|
+
const refs = [];
|
|
56
|
+
if (record.owner.taskId === undefined || !state.taskStatusById.has(record.owner.taskId)) {
|
|
57
|
+
refs.push("owner-unproven");
|
|
58
|
+
}
|
|
59
|
+
else if (!isTerminalTaskStatus(state.taskStatusById.get(record.owner.taskId))) {
|
|
60
|
+
refs.push("owner-not-terminal");
|
|
61
|
+
}
|
|
62
|
+
const paths = [
|
|
63
|
+
...state.managedWorkspaces.flatMap(workspace => [workspace.root, ...workspace.entries.map(entry => entry.path)]),
|
|
64
|
+
...state.activeWorkspaceOwnerPaths
|
|
65
|
+
];
|
|
66
|
+
if (paths.some(path => within(path, record.path) || within(record.path, path)))
|
|
67
|
+
refs.push("durable-owner");
|
|
68
|
+
const custody = readSessionOwnerClaims(state.sessionOwners);
|
|
69
|
+
if (custody.diagnostics.some(diagnostic => diagnostic.severity === "error"))
|
|
70
|
+
refs.push("session-owner-unproven");
|
|
71
|
+
if (custody.claims.some(claim => within(claim.path, record.path) || within(record.path, claim.path))) {
|
|
72
|
+
refs.push("live-session-owner");
|
|
73
|
+
}
|
|
74
|
+
return refs;
|
|
75
|
+
}
|
|
30
76
|
/**
|
|
31
77
|
* Plan a GC pass: discover resources, scan live references, and classify each
|
|
32
78
|
* record. Planning is strictly read-only — it never writes the registry,
|
|
@@ -34,7 +80,7 @@ export const DEFAULT_QUARANTINE_TTL_HOURS = 24;
|
|
|
34
80
|
*/
|
|
35
81
|
export async function planResourceGc(input) {
|
|
36
82
|
const home = resolve(input.home);
|
|
37
|
-
const
|
|
83
|
+
const registry = withResourceRegistry(home, input.registryStore, store => store.load());
|
|
38
84
|
const discovered = await discoverResources({
|
|
39
85
|
home,
|
|
40
86
|
projects: input.projects,
|
|
@@ -45,10 +91,10 @@ export async function planResourceGc(input) {
|
|
|
45
91
|
const paths = discovered.map(({ record }) => record.path);
|
|
46
92
|
// Also scan original paths of quarantined records so a new reference is
|
|
47
93
|
// visible in the plan (restore itself happens in apply/purge/restore-all).
|
|
48
|
-
const registry = registryStore.load();
|
|
49
94
|
appendRegistryScanPaths(registry, paths, home);
|
|
50
95
|
const scan = await scanLiveReferences({
|
|
51
96
|
home,
|
|
97
|
+
sessionOwners: input.sessionOwners,
|
|
52
98
|
paths,
|
|
53
99
|
environment: input.environment,
|
|
54
100
|
tmuxServerName: input.tmuxServerName,
|
|
@@ -211,11 +257,11 @@ function dirtyWorktreeSuggestion(record) {
|
|
|
211
257
|
* Apply a GC plan: quarantine every releasable resource. In `report` mode this
|
|
212
258
|
* is a shadow pass — the plan is returned unchanged and nothing is mutated.
|
|
213
259
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
260
|
+
* Physical discovery is refreshed, each subtree gets a live-reference scan,
|
|
261
|
+
* and current durable ownership is checked under the existing writer fence.
|
|
262
|
+
* No registry connection is held across an asynchronous scan.
|
|
217
263
|
*/
|
|
218
|
-
export async function applyResourceGc(input, plan) {
|
|
264
|
+
export async function applyResourceGc(input, plan, store) {
|
|
219
265
|
if (plan.mode === "report") {
|
|
220
266
|
return Object.freeze({
|
|
221
267
|
planned: plan,
|
|
@@ -226,95 +272,113 @@ export async function applyResourceGc(input, plan) {
|
|
|
226
272
|
});
|
|
227
273
|
}
|
|
228
274
|
const home = resolve(input.home);
|
|
275
|
+
if (resolve(store.rootDirectory()) !== home || resolve(plan.home) !== home) {
|
|
276
|
+
throw new Error("GC plan, ownership Store and resource Home must match.");
|
|
277
|
+
}
|
|
229
278
|
const now = input.now;
|
|
230
|
-
const registryStore = input.registryStore ?? createResourceRegistryStore(home);
|
|
231
|
-
let registry = registryStore.load();
|
|
232
279
|
const applied = [];
|
|
233
280
|
const failed = [];
|
|
234
281
|
const restored = [];
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
managedWorkspaces: input.managedWorkspaces,
|
|
241
|
-
taskStatusById: input.taskStatusById,
|
|
242
|
-
now
|
|
243
|
-
});
|
|
244
|
-
const freshPaths = discovered.map(({ record }) => record.path);
|
|
245
|
-
appendRegistryScanPaths(registry, freshPaths, home);
|
|
246
|
-
const freshScan = await scanLiveReferences({
|
|
247
|
-
home,
|
|
248
|
-
paths: freshPaths,
|
|
249
|
-
environment: input.environment,
|
|
250
|
-
tmuxServerName: input.tmuxServerName,
|
|
251
|
-
ports: {
|
|
252
|
-
...input.liveReferencePorts,
|
|
253
|
-
managedWorkspaces: () => input.managedWorkspaces,
|
|
254
|
-
activeWorkspaceOwners: () => collectActiveWorkspaceOwners(input)
|
|
255
|
-
}
|
|
282
|
+
const fresh = await planResourceGc({ ...input, ...readResourceGcState(store) });
|
|
283
|
+
const freshById = new Map(fresh.records.map(record => [record.id, record]));
|
|
284
|
+
const retained = new Map(fresh.retained.map(record => [record.id, record]));
|
|
285
|
+
const retain = (record, reason) => retained.set(record.id, {
|
|
286
|
+
...record, disposition: "retained-unproven", blocker: reason, updatedAt: now.toISOString()
|
|
256
287
|
});
|
|
257
|
-
const
|
|
258
|
-
const
|
|
288
|
+
const inspectedRegistry = withResourceRegistry(home, input.registryStore, registry => registry.load());
|
|
289
|
+
const movedRoots = [];
|
|
259
290
|
// Restore quarantined records whose owner is no longer terminal or that
|
|
260
291
|
// gained a live reference. This is the apply-time counterpart of the old
|
|
261
292
|
// planning-time restore: planning is read-only, so restore happens here.
|
|
262
|
-
for (const record of Object.values(
|
|
293
|
+
for (const record of Object.values(inspectedRegistry.records)) {
|
|
263
294
|
if (record.disposition !== "quarantined" || record.quarantine === undefined)
|
|
264
295
|
continue;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
failed.push(restoredRecord);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
296
|
+
withResourceRegistry(home, input.registryStore, registry => registry.transaction(() => {
|
|
297
|
+
const before = registry.load();
|
|
298
|
+
if (!isDeepStrictEqual(before.records[record.id], record))
|
|
299
|
+
throw new Error("Quarantine ownership changed; inspect and retry.");
|
|
300
|
+
const refs = [...(fresh.scan.refsByPath.get(record.quarantine.originalPath) ?? []),
|
|
301
|
+
...currentDurableRefs(record, readResourceGcState(store)).filter(ref => ref !== "owner-unproven")];
|
|
302
|
+
if (refs.length === 0)
|
|
303
|
+
return;
|
|
304
|
+
const result = restoreQuarantinedRecord(record, refs, now);
|
|
305
|
+
registry.save(upsertResourceRecord(before, result), before);
|
|
306
|
+
(result.disposition === "active" ? restored : failed).push(result);
|
|
307
|
+
}));
|
|
280
308
|
}
|
|
281
|
-
|
|
309
|
+
// A physical parent owns one move. Its unreferenced runtime descendants are
|
|
310
|
+
// redundant registry receipts, not separately movable resources.
|
|
311
|
+
for (const planned of [...plan.records].sort((a, b) => a.path.length - b.path.length)) {
|
|
282
312
|
if (!isReleasable(planned))
|
|
283
313
|
continue;
|
|
284
|
-
|
|
285
|
-
// be clean, and still have zero live references.
|
|
286
|
-
const fresh = freshById.get(planned.id);
|
|
287
|
-
if (fresh === undefined) {
|
|
288
|
-
// Resource disappeared since planning; reconcile the stale record.
|
|
289
|
-
registry = removeResourceRecord(registry, planned.id);
|
|
314
|
+
if (movedRoots.some(root => within(planned.path, root)))
|
|
290
315
|
continue;
|
|
291
|
-
|
|
292
|
-
if (!isReleasable(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
316
|
+
const candidate = freshById.get(planned.id);
|
|
317
|
+
if (!candidate || !isReleasable(candidate))
|
|
318
|
+
continue;
|
|
319
|
+
const descendants = fresh.records.filter(record => record.id !== candidate.id
|
|
320
|
+
&& within(record.path, candidate.path) && existsSync(record.path));
|
|
321
|
+
// Do not move a directory around independent Git worktrees or retained
|
|
322
|
+
// descendants. Their own owner must release them first.
|
|
323
|
+
if (descendants.some(record => record.kind !== "runtime-artifact" || !isReleasable(record))) {
|
|
324
|
+
retain(candidate, "Directory contains independently owned or retained resources.");
|
|
296
325
|
continue;
|
|
297
326
|
}
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
327
|
+
const scan = await scanLiveReferences({
|
|
328
|
+
home, sessionOwners: store.listSessionOwners(), paths: [candidate.path, ...descendants.map(record => record.path)],
|
|
329
|
+
environment: input.environment, tmuxServerName: input.tmuxServerName,
|
|
330
|
+
ports: input.liveReferencePorts
|
|
331
|
+
});
|
|
332
|
+
if (scan.diagnostics.some(d => d.severity === "error")
|
|
333
|
+
|| [...scan.refsByPath.values()].some(refs => refs.length > 0)) {
|
|
334
|
+
retain(candidate, "Current physical references prevent this subtree move.");
|
|
335
|
+
continue;
|
|
305
336
|
}
|
|
337
|
+
withResourceRegistry(home, input.registryStore, registry => registry.transaction(() => {
|
|
338
|
+
const before = registry.load();
|
|
339
|
+
const subtree = Object.values(before.records).filter(record => within(record.path, candidate.path)
|
|
340
|
+
&& existsSync(record.path));
|
|
341
|
+
for (const record of [candidate, ...subtree]) {
|
|
342
|
+
if (!isDeepStrictEqual(before.records[record.id], inspectedRegistry.records[record.id])) {
|
|
343
|
+
throw new Error(`Resource registry ownership changed: ${record.id}; inspect and retry.`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const state = readResourceGcState(store);
|
|
347
|
+
if ([candidate, ...subtree].some(record => currentDurableRefs(record, state).length > 0)) {
|
|
348
|
+
retain(candidate, "Current Task or workspace ownership prevents this subtree move.");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!existsSync(candidate.path))
|
|
352
|
+
return;
|
|
353
|
+
const result = quarantineResource(home, candidate, now);
|
|
354
|
+
let next = upsertResourceRecord(before, result.record);
|
|
355
|
+
if (result.ok) {
|
|
356
|
+
for (const record of subtree) {
|
|
357
|
+
if (record.id !== candidate.id)
|
|
358
|
+
next = removeResourceRecord(next, record.id);
|
|
359
|
+
}
|
|
360
|
+
movedRoots.push(candidate.path);
|
|
361
|
+
applied.push(result.record);
|
|
362
|
+
}
|
|
363
|
+
else
|
|
364
|
+
failed.push(result.record);
|
|
365
|
+
registry.save(next, before);
|
|
366
|
+
}));
|
|
306
367
|
}
|
|
307
|
-
registryStore.save(registry);
|
|
308
|
-
registryStore.close();
|
|
309
368
|
return Object.freeze({
|
|
310
|
-
planned:
|
|
369
|
+
planned: {
|
|
370
|
+
...fresh,
|
|
371
|
+
records: fresh.records.map(record => retained.get(record.id) ?? record),
|
|
372
|
+
retained: [...retained.values()],
|
|
373
|
+
releasable: fresh.releasable.filter(record => !retained.has(record.id))
|
|
374
|
+
},
|
|
311
375
|
applied: Object.freeze(applied),
|
|
312
376
|
failed: Object.freeze(failed),
|
|
313
377
|
restored: Object.freeze(restored),
|
|
314
378
|
purged: Object.freeze([])
|
|
315
379
|
});
|
|
316
380
|
}
|
|
317
|
-
|
|
381
|
+
function quarantineResource(home, record, now) {
|
|
318
382
|
const quarantineRoot = resourceQuarantineRoot(home);
|
|
319
383
|
const quarantinePath = join(quarantineRoot, record.id);
|
|
320
384
|
const receiptPath = `${quarantinePath}.receipt.json`;
|
|
@@ -324,7 +388,9 @@ async function quarantineResource(home, record, now) {
|
|
|
324
388
|
// metadata, into the Home-local quarantine. Restore is the inverse
|
|
325
389
|
// move, so the recorded HEAD cannot drift with a branch.
|
|
326
390
|
mkdirSync(quarantineRoot, { recursive: true, mode: 0o700 });
|
|
327
|
-
|
|
391
|
+
// Keep the existing SQLite writer fence through this bounded move, so a
|
|
392
|
+
// Task reopen or new durable owner cannot commit between check and move.
|
|
393
|
+
execFileSync("git", ["-C", record.path, "worktree", "move", "--", record.path, quarantinePath], { timeout: 30_000 });
|
|
328
394
|
writeQuarantineReceipt(home, record, now, "move", receiptPath);
|
|
329
395
|
return {
|
|
330
396
|
ok: true,
|
|
@@ -379,7 +445,7 @@ async function quarantineResource(home, record, now) {
|
|
|
379
445
|
};
|
|
380
446
|
}
|
|
381
447
|
}
|
|
382
|
-
function writeQuarantineReceipt(
|
|
448
|
+
function writeQuarantineReceipt(_home, record, now, method, receiptPath) {
|
|
383
449
|
try {
|
|
384
450
|
mkdirSync(dirname(receiptPath), { recursive: true, mode: 0o700 });
|
|
385
451
|
writeFileSync(receiptPath, `${JSON.stringify({
|
|
@@ -403,58 +469,39 @@ function isGitWorktreePath(path) {
|
|
|
403
469
|
/**
|
|
404
470
|
* Restore a quarantined resource to its original path. Move-based Git
|
|
405
471
|
* quarantine is reversed with `git worktree move`, preserving the exact
|
|
406
|
-
* checkout.
|
|
407
|
-
*
|
|
472
|
+
* checkout. A different recorded method remains audit evidence and requires
|
|
473
|
+
* separate recovery; it is never reinterpreted as a move or a fresh checkout.
|
|
408
474
|
*/
|
|
409
475
|
function restoreQuarantinedRecord(record, liveRefs, now) {
|
|
410
476
|
const quarantine = record.quarantine;
|
|
411
477
|
if (quarantine === undefined)
|
|
412
478
|
return record;
|
|
413
479
|
try {
|
|
414
|
-
if (quarantine.method
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
480
|
+
if (quarantine.method !== "move" || !["worktree", "runtime-artifact"].includes(record.kind)) {
|
|
481
|
+
throw new Error(`Unsupported quarantine provenance: ${record.kind}/${quarantine.method}; preserve its evidence for explicit recovery.`);
|
|
482
|
+
}
|
|
483
|
+
if (!existsSync(quarantine.path)) {
|
|
484
|
+
return {
|
|
485
|
+
...record,
|
|
486
|
+
disposition: "cleanup-failed",
|
|
487
|
+
blocker: `Restore failed: quarantine path missing: ${quarantine.path}`,
|
|
488
|
+
updatedAt: now.toISOString()
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (existsSync(quarantine.originalPath)) {
|
|
492
|
+
return {
|
|
493
|
+
...record,
|
|
494
|
+
disposition: "cleanup-failed",
|
|
495
|
+
blocker: `Restore failed: original path already exists: ${quarantine.originalPath}`,
|
|
496
|
+
updatedAt: now.toISOString()
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
mkdirSync(dirname(quarantine.originalPath), { recursive: true });
|
|
500
|
+
if (isGitWorktreePath(quarantine.path)) {
|
|
501
|
+
execFileSync("git", ["-C", quarantine.path, "worktree", "move", "--", quarantine.path, quarantine.originalPath], { timeout: 30_000 });
|
|
431
502
|
}
|
|
432
503
|
else {
|
|
433
|
-
|
|
434
|
-
if (!existsSync(quarantine.path)) {
|
|
435
|
-
return {
|
|
436
|
-
...record,
|
|
437
|
-
disposition: "cleanup-failed",
|
|
438
|
-
blocker: `Restore failed: quarantine path missing: ${quarantine.path}`,
|
|
439
|
-
updatedAt: now.toISOString()
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
if (existsSync(quarantine.originalPath)) {
|
|
443
|
-
return {
|
|
444
|
-
...record,
|
|
445
|
-
disposition: "cleanup-failed",
|
|
446
|
-
blocker: `Restore failed: original path already exists: ${quarantine.originalPath}`,
|
|
447
|
-
updatedAt: now.toISOString()
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
if (isGitWorktreePath(quarantine.path)) {
|
|
451
|
-
mkdirSync(dirname(quarantine.originalPath), { recursive: true });
|
|
452
|
-
execFileSync("git", ["-C", quarantine.path, "worktree", "move", "--", quarantine.path, quarantine.originalPath], { timeout: 30_000 });
|
|
453
|
-
}
|
|
454
|
-
else {
|
|
455
|
-
mkdirSync(dirname(quarantine.originalPath), { recursive: true });
|
|
456
|
-
renameSync(quarantine.path, quarantine.originalPath);
|
|
457
|
-
}
|
|
504
|
+
renameSync(quarantine.path, quarantine.originalPath);
|
|
458
505
|
}
|
|
459
506
|
}
|
|
460
507
|
catch (error) {
|
|
@@ -479,12 +526,13 @@ function restoreQuarantinedRecord(record, liveRefs, now) {
|
|
|
479
526
|
* elapsed. A resource with a new live reference — on either the original or
|
|
480
527
|
* the quarantine path — is restored instead.
|
|
481
528
|
*/
|
|
482
|
-
export async function purgeResourceQuarantine(home, options) {
|
|
529
|
+
export async function purgeResourceQuarantine(home, options, store) {
|
|
483
530
|
const resolvedHome = resolve(home);
|
|
531
|
+
if (resolve(store.rootDirectory()) !== resolvedHome)
|
|
532
|
+
throw new Error("GC ownership Store belongs to another Home.");
|
|
484
533
|
const ttlHours = options.ttlHours ?? DEFAULT_QUARANTINE_TTL_HOURS;
|
|
485
534
|
const ttlMs = ttlHours * 3_600_000;
|
|
486
|
-
const
|
|
487
|
-
const registry = registryStore.load();
|
|
535
|
+
const registry = withResourceRegistry(resolvedHome, undefined, store => store.load());
|
|
488
536
|
const quarantined = Object.values(registry.records)
|
|
489
537
|
.filter((record) => record.quarantine !== undefined
|
|
490
538
|
&& (record.disposition === "quarantined" || record.disposition === "cleanup-failed"));
|
|
@@ -499,6 +547,7 @@ export async function purgeResourceQuarantine(home, options) {
|
|
|
499
547
|
}
|
|
500
548
|
const scan = await scanLiveReferences({
|
|
501
549
|
home: resolvedHome,
|
|
550
|
+
sessionOwners: store.listSessionOwners(),
|
|
502
551
|
paths,
|
|
503
552
|
environment: options.environment,
|
|
504
553
|
tmuxServerName: options.tmuxServerName,
|
|
@@ -508,7 +557,6 @@ export async function purgeResourceQuarantine(home, options) {
|
|
|
508
557
|
activeWorkspaceOwners: () => []
|
|
509
558
|
}
|
|
510
559
|
});
|
|
511
|
-
let state = registry;
|
|
512
560
|
const purged = [];
|
|
513
561
|
const restored = [];
|
|
514
562
|
const failed = [];
|
|
@@ -524,58 +572,64 @@ export async function purgeResourceQuarantine(home, options) {
|
|
|
524
572
|
// resource quarantined instead of permanently deleting it.
|
|
525
573
|
if (scanUntrusted)
|
|
526
574
|
continue;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
575
|
+
withResourceRegistry(resolvedHome, undefined, registryStore => registryStore.transaction(() => {
|
|
576
|
+
const before = registryStore.load();
|
|
577
|
+
if (!isDeepStrictEqual(before.records[record.id], record))
|
|
578
|
+
throw new Error("Quarantine ownership changed; inspect and retry.");
|
|
579
|
+
const save = (next) => registryStore.save(upsertResourceRecord(before, next), before);
|
|
580
|
+
if (quarantine.method !== "move" || !["worktree", "runtime-artifact"].includes(record.kind)) {
|
|
581
|
+
const retained = { ...record, disposition: "cleanup-failed",
|
|
582
|
+
blocker: `Unsupported quarantine provenance: ${record.kind}/${quarantine.method}; preserve its evidence for explicit recovery.`,
|
|
583
|
+
updatedAt: options.now.toISOString() };
|
|
584
|
+
save(retained);
|
|
585
|
+
failed.push(retained);
|
|
586
|
+
return;
|
|
535
587
|
}
|
|
536
|
-
|
|
537
|
-
|
|
588
|
+
const state = readResourceGcState(store);
|
|
589
|
+
if (record.owner.taskId === undefined || !state.taskStatusById.has(record.owner.taskId)) {
|
|
590
|
+
save({ ...record, blocker: "Current Task ownership cannot be proven; quarantine retained.", updatedAt: options.now.toISOString() });
|
|
591
|
+
return;
|
|
538
592
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
593
|
+
const allRefs = [
|
|
594
|
+
...(scan.refsByPath.get(quarantine.originalPath) ?? []),
|
|
595
|
+
...(scan.refsByPath.get(quarantine.path) ?? []),
|
|
596
|
+
...currentDurableRefs(record, state)
|
|
597
|
+
];
|
|
598
|
+
if (allRefs.length > 0) {
|
|
599
|
+
const result = restoreQuarantinedRecord(record, allRefs, options.now);
|
|
600
|
+
save(result);
|
|
601
|
+
(result.disposition === "active" ? restored : failed).push(result);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
if (existsSync(quarantine.path)) {
|
|
606
|
+
if (isGitWorktreePath(quarantine.path)) {
|
|
607
|
+
execFileSync("git", ["-C", quarantine.path, "worktree", "remove", "--force", "--", quarantine.path], { timeout: 30_000 });
|
|
608
|
+
}
|
|
609
|
+
rmSync(quarantine.path, { recursive: true, force: true });
|
|
545
610
|
}
|
|
546
|
-
|
|
611
|
+
const receiptPath = `${quarantine.path}.receipt.json`;
|
|
612
|
+
if (existsSync(receiptPath))
|
|
613
|
+
rmSync(receiptPath, { force: true });
|
|
614
|
+
const deleted = {
|
|
615
|
+
...record, disposition: "deleted", quarantine: undefined,
|
|
616
|
+
cleanupReceipt: { removedAt: options.now.toISOString(), method: "quarantine-purge" },
|
|
617
|
+
updatedAt: options.now.toISOString()
|
|
618
|
+
};
|
|
619
|
+
save(deleted);
|
|
620
|
+
purged.push(deleted);
|
|
547
621
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
622
|
+
catch (error) {
|
|
623
|
+
const failedRecord = {
|
|
624
|
+
...record, disposition: "cleanup-failed",
|
|
625
|
+
blocker: `Purge failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
626
|
+
updatedAt: options.now.toISOString()
|
|
627
|
+
};
|
|
628
|
+
save(failedRecord);
|
|
629
|
+
failed.push(failedRecord);
|
|
552
630
|
}
|
|
553
|
-
|
|
554
|
-
...record,
|
|
555
|
-
disposition: "deleted",
|
|
556
|
-
quarantine: undefined,
|
|
557
|
-
cleanupReceipt: {
|
|
558
|
-
removedAt: options.now.toISOString(),
|
|
559
|
-
method: "quarantine-purge"
|
|
560
|
-
},
|
|
561
|
-
updatedAt: options.now.toISOString()
|
|
562
|
-
};
|
|
563
|
-
state = upsertResourceRecord(state, deleted);
|
|
564
|
-
purged.push(deleted);
|
|
565
|
-
}
|
|
566
|
-
catch (error) {
|
|
567
|
-
const failedRecord = {
|
|
568
|
-
...record,
|
|
569
|
-
disposition: "cleanup-failed",
|
|
570
|
-
blocker: `Purge failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
571
|
-
updatedAt: options.now.toISOString()
|
|
572
|
-
};
|
|
573
|
-
state = upsertResourceRecord(state, failedRecord);
|
|
574
|
-
failed.push(failedRecord);
|
|
575
|
-
}
|
|
631
|
+
}));
|
|
576
632
|
}
|
|
577
|
-
registryStore.save(state);
|
|
578
|
-
registryStore.close();
|
|
579
633
|
return Object.freeze({
|
|
580
634
|
planned: Object.freeze({
|
|
581
635
|
home: resolvedHome,
|
|
@@ -601,8 +655,7 @@ export async function purgeResourceQuarantine(home, options) {
|
|
|
601
655
|
*/
|
|
602
656
|
export async function restoreAllResourceGc(home, options) {
|
|
603
657
|
const resolvedHome = resolve(home);
|
|
604
|
-
const
|
|
605
|
-
const registry = registryStore.load();
|
|
658
|
+
const registry = withResourceRegistry(resolvedHome, undefined, store => store.load());
|
|
606
659
|
const quarantined = Object.values(registry.records)
|
|
607
660
|
.filter((record) => record.quarantine !== undefined
|
|
608
661
|
&& (record.disposition === "quarantined" || record.disposition === "cleanup-failed"));
|
|
@@ -619,8 +672,7 @@ export async function restoreAllResourceGc(home, options) {
|
|
|
619
672
|
failed.push(restoredRecord);
|
|
620
673
|
}
|
|
621
674
|
}
|
|
622
|
-
|
|
623
|
-
registryStore.close();
|
|
675
|
+
withResourceRegistry(resolvedHome, undefined, store => store.save(state, registry));
|
|
624
676
|
return Object.freeze({
|
|
625
677
|
planned: Object.freeze({
|
|
626
678
|
home: resolvedHome,
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { basename, dirname, resolve } from "node:path";
|
|
12
|
-
import { createResourceRegistryStore } from "./resourceRegistryStore.js";
|
|
13
12
|
import { upsertResourceRecord } from "./resourceRegistry.js";
|
|
13
|
+
import { createResourceRegistryStore } from "./resourceRegistryStore.js";
|
|
14
14
|
import { createResourceRecord, isReleaseNamespacePath, resourceId } from "./resourceTypes.js";
|
|
15
15
|
export class ResourceRegistrar {
|
|
16
16
|
#home;
|
|
@@ -142,7 +142,7 @@ export class ResourceRegistrar {
|
|
|
142
142
|
});
|
|
143
143
|
}
|
|
144
144
|
if (next !== state)
|
|
145
|
-
store.save(next);
|
|
145
|
+
store.save(next, state);
|
|
146
146
|
}
|
|
147
147
|
finally {
|
|
148
148
|
store.close();
|
|
@@ -175,10 +175,11 @@ export class ResourceRegistrar {
|
|
|
175
175
|
return;
|
|
176
176
|
const store = createResourceRegistryStore(this.#home);
|
|
177
177
|
try {
|
|
178
|
-
|
|
178
|
+
const previous = store.load();
|
|
179
|
+
let state = previous;
|
|
179
180
|
for (const record of records)
|
|
180
181
|
state = upsertResourceRecord(state, record);
|
|
181
|
-
store.save(state);
|
|
182
|
+
store.save(state, previous);
|
|
182
183
|
}
|
|
183
184
|
finally {
|
|
184
185
|
store.close();
|