@zq-silk/yui 0.15.9 → 0.15.12
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 +8 -4
- package/ARCHITECTURE.zh-CN.md +5 -2
- package/README.md +13 -5
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/agentRun/agentRun.js +3 -0
- package/dist/cli/commandCatalog.js +64 -16
- package/dist/cli/interactionPolicy.js +7 -3
- package/dist/cli/managedDiagnostics.js +1 -1
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +381 -107
- package/dist/commands/executionAuditCommands.js +10 -0
- package/dist/commands/globalRoleCommands.js +339 -4
- package/dist/commands/projectCommands.js +50 -22
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +586 -96
- package/dist/commands/taskIntegrationCommands.js +19 -39
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskOverviewCommand.js +4 -3
- package/dist/commands/taskPublicationAdoptCommand.js +127 -0
- package/dist/commands/taskPublicationCommands.js +11 -2
- package/dist/commands/taskPublicationVerifyCommand.js +23 -39
- package/dist/commands/taskRemoteDeliveryCommand.js +22 -11
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/runContextPack.js +3 -0
- package/dist/context/taskCatalog.js +187 -0
- package/dist/context/taskContext.js +55 -6
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +14 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +519 -25
- package/dist/controller/globalInputDelivery.js +132 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/providerRetryAdmission.js +100 -0
- package/dist/controller/providerRetryDelivery.js +218 -0
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +117 -7
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +329 -386
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/lifecycle/exactRunTerminalization.js +4 -1
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/globalProviderRetry.js +15 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/executionAudit.js +19 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +430 -107
- package/dist/repository/projectMaintenanceLock.js +75 -18
- package/dist/repository/taskWorkspaceCoordinator.js +182 -101
- package/dist/repository/taskWorkspacePreparer.js +205 -72
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/repository/workspaceCleanupInspection.js +187 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentError.js +5 -3
- package/dist/runtime/agentHost.js +179 -82
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/builtinAgentErrorMappers.js +91 -0
- package/dist/runtime/codexAppServerRuntime.js +34 -3
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/providerControl.js +5 -1
- package/dist/runtime/providerRetry.js +198 -0
- package/dist/runtime/providerRuntimeIdentity.js +28 -2
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/sessionTokenMetrics.js +15 -5
- package/dist/runtime/structuredProviderHost.js +6 -2
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/taskUsageMetrics.js +275 -0
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/activeRoleRunDelivery.js +12 -0
- package/dist/scheduler/leaderWakeupProcessor.js +5 -0
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +38 -6
- package/dist/scheduler/taskObservabilityProjection.js +6 -44
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +167 -4
- package/dist/storage/sqliteStore.js +57 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -0
- package/dist/storage/taskCatalog.js +123 -0
- package/dist/storage/taskStore.js +2 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +129 -0
- package/dist/task/archivePreflight.js +124 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/publicationAdoption.js +56 -0
- package/dist/task/publicationReference.js +10 -0
- package/dist/task/remoteDelivery.js +31 -16
- package/dist/web/assets/client/app.js +147 -17
- package/dist/web/assets/client/components.js +56 -13
- package/dist/web/assets/client/i18n.js +78 -4
- package/dist/web/assets/client/taskSurface.js +108 -1
- package/dist/web/assets/client/view.js +39 -8
- package/dist/web/assets/shell.js +29 -0
- package/dist/web/assets/styles/layout.js +8 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +131 -4
- package/dist/web/webSnapshot.js +16 -6
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/cleanupInspection.js +63 -0
- package/dist/workspace/workItemChangeSetManager.js +111 -35
- package/docs/agent-result-consumption.md +4 -0
- package/docs/agent-result-consumption.zh-CN.md +3 -0
- package/docs/agent-runtime-drivers.md +7 -0
- package/docs/agent-runtime-drivers.zh-CN.md +5 -0
- package/docs/architecture/README.md +2 -0
- package/docs/architecture/README.zh-CN.md +3 -1
- package/docs/architecture/capabilities-and-resources.md +30 -5
- package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
- package/docs/managed-turn-and-session-runtime.md +47 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
- package/docs/observability/README.md +62 -0
- package/docs/observability/README.zh-CN.md +47 -0
- package/docs/project-refresh.md +77 -0
- package/docs/project-refresh.zh-CN.md +59 -0
- package/docs/provider-retry.md +70 -0
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/task-delivery.md +133 -13
- package/docs/task-delivery.zh-CN.md +99 -10
- package/docs/task-discovery.md +102 -0
- package/docs/task-discovery.zh-CN.md +86 -0
- package/docs/testing/verification-levels.md +40 -0
- package/docs/testing/verification-levels.zh-CN.md +23 -0
- package/i18n/README.zh-CN.md +13 -7
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +154 -51
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +19 -3
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-runtime/SKILL.md +42 -0
- package/skills/yui-runtime/references/publication.md +42 -0
- package/skills/yui-runtime/references/recovery.md +24 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
5
|
/**
|
|
4
6
|
* Per-Project maintenance fence.
|
|
5
7
|
*
|
|
@@ -12,35 +14,66 @@ import { join } from "node:path";
|
|
|
12
14
|
* gone; the owner file records PID + process start time, so a recycled PID
|
|
13
15
|
* can never pass for the original holder.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Contenders wait asynchronously within one monotonic budget. Only lock
|
|
18
|
+
* acquisition is retried; the caller's protected operation is never replayed.
|
|
17
19
|
*/
|
|
18
|
-
const PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS =
|
|
19
|
-
const
|
|
20
|
+
const PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS = 60_000;
|
|
21
|
+
const PROJECT_MAINTENANCE_LOCK_RETRY_MIN_MS = 200;
|
|
22
|
+
const PROJECT_MAINTENANCE_LOCK_RETRY_MAX_MS = 500;
|
|
20
23
|
/** A lock older than this with a dead owner is reclaimed. */
|
|
21
24
|
const STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS = 1_000;
|
|
22
25
|
/**
|
|
23
|
-
* Raised when
|
|
24
|
-
*
|
|
26
|
+
* Raised when acquisition exhausted its budget. This acquisition has not
|
|
27
|
+
* started the protected operation; callers retain responsibility for any
|
|
28
|
+
* earlier effects and must not replay the whole operation implicitly.
|
|
25
29
|
*/
|
|
26
30
|
export class ProjectMaintenanceLockedError extends Error {
|
|
31
|
+
timeoutMs;
|
|
27
32
|
projectId;
|
|
28
33
|
retryable = true;
|
|
29
|
-
constructor(projectId) {
|
|
30
|
-
super(`
|
|
34
|
+
constructor(projectId, timeoutMs = PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS) {
|
|
35
|
+
super(`Timed out after ${timeoutMs}ms waiting for Project maintenance on ${projectId}; `
|
|
36
|
+
+ "the lock was not acquired. Read current state before retrying.");
|
|
37
|
+
this.timeoutMs = timeoutMs;
|
|
31
38
|
this.name = "ProjectMaintenanceLockedError";
|
|
32
39
|
this.projectId = projectId;
|
|
33
40
|
}
|
|
34
41
|
}
|
|
42
|
+
export class ProjectMaintenanceLockCancelledError extends Error {
|
|
43
|
+
projectId;
|
|
44
|
+
constructor(projectId, cause) {
|
|
45
|
+
super(`Cancelled waiting for Project maintenance on ${projectId}; the lock was not acquired.`, { cause });
|
|
46
|
+
this.projectId = projectId;
|
|
47
|
+
this.name = "ProjectMaintenanceLockCancelledError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function acquisitionBudget(options) {
|
|
51
|
+
const timeoutMs = options.timeoutMs ?? PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS;
|
|
52
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
53
|
+
throw new RangeError("Project maintenance timeout must be a finite nonnegative number.");
|
|
54
|
+
}
|
|
55
|
+
const now = options.now ?? (() => performance.now());
|
|
56
|
+
return {
|
|
57
|
+
timeoutMs,
|
|
58
|
+
deadline: now() + timeoutMs,
|
|
59
|
+
now,
|
|
60
|
+
random: options.random ?? Math.random,
|
|
61
|
+
wait: options.wait ?? ((milliseconds, signal) => delay(milliseconds, undefined, { signal })),
|
|
62
|
+
signal: options.signal
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function assertNotCancelled(projectId, signal) {
|
|
66
|
+
if (signal?.aborted)
|
|
67
|
+
throw new ProjectMaintenanceLockCancelledError(projectId, signal.reason);
|
|
68
|
+
}
|
|
35
69
|
/** Directory of one Project's maintenance fence, below the Home's locks area. */
|
|
36
70
|
export function projectMaintenanceLockPath(home, projectId) {
|
|
37
71
|
return join(home, "locks", "projects", `${projectId}.lock`);
|
|
38
72
|
}
|
|
39
73
|
/**
|
|
40
74
|
* Acquire one Project's maintenance fence. Returns the release function;
|
|
41
|
-
* callers MUST release on every exit path (try/finally). A live holder
|
|
42
|
-
*
|
|
43
|
-
* holder is reclaimed.
|
|
75
|
+
* callers MUST release on every exit path (try/finally). A live holder is
|
|
76
|
+
* never stolen, including at timeout; a stale (dead) holder is reclaimed.
|
|
44
77
|
*
|
|
45
78
|
* There is no in-process reentrancy: every acquisition contends, so two
|
|
46
79
|
* independent operations in the same process are mutually exclusive just
|
|
@@ -48,11 +81,22 @@ export function projectMaintenanceLockPath(home, projectId) {
|
|
|
48
81
|
* fence while its caller already holds it) goes through private
|
|
49
82
|
* already-locked methods, never through a second acquisition.
|
|
50
83
|
*/
|
|
51
|
-
export function acquireProjectMaintenanceLock(home, projectId) {
|
|
84
|
+
export function acquireProjectMaintenanceLock(home, projectId, options = {}) {
|
|
85
|
+
return acquireWithBudget(home, projectId, acquisitionBudget(options));
|
|
86
|
+
}
|
|
87
|
+
async function acquireWithBudget(home, projectId, budget, firstProject = true) {
|
|
52
88
|
const lock = projectMaintenanceLockPath(home, projectId);
|
|
89
|
+
assertNotCancelled(projectId, budget.signal);
|
|
53
90
|
mkdirSync(join(home, "locks", "projects"), { recursive: true, mode: 0o700 });
|
|
54
|
-
|
|
91
|
+
let firstAttempt = firstProject;
|
|
55
92
|
while (true) {
|
|
93
|
+
assertNotCancelled(projectId, budget.signal);
|
|
94
|
+
// Always try the first Project immediately, even for a zero-budget probe.
|
|
95
|
+
// Every retry and subsequent Project shares the original deadline.
|
|
96
|
+
if (!firstAttempt && budget.now() >= budget.deadline) {
|
|
97
|
+
throw new ProjectMaintenanceLockedError(projectId, budget.timeoutMs);
|
|
98
|
+
}
|
|
99
|
+
firstAttempt = false;
|
|
56
100
|
try {
|
|
57
101
|
mkdirSync(lock, { mode: 0o700 });
|
|
58
102
|
const ownerIdentity = writeOwnerIdentity(lock);
|
|
@@ -70,10 +114,22 @@ export function acquireProjectMaintenanceLock(home, projectId) {
|
|
|
70
114
|
catch (error) {
|
|
71
115
|
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
72
116
|
throw error;
|
|
117
|
+
if (budget.now() >= budget.deadline) {
|
|
118
|
+
throw new ProjectMaintenanceLockedError(projectId, budget.timeoutMs);
|
|
119
|
+
}
|
|
73
120
|
reclaimStaleProjectMaintenanceLock(lock);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
121
|
+
const remaining = budget.deadline - budget.now();
|
|
122
|
+
if (remaining <= 0)
|
|
123
|
+
throw new ProjectMaintenanceLockedError(projectId, budget.timeoutMs);
|
|
124
|
+
const jitter = PROJECT_MAINTENANCE_LOCK_RETRY_MIN_MS
|
|
125
|
+
+ budget.random() * (PROJECT_MAINTENANCE_LOCK_RETRY_MAX_MS - PROJECT_MAINTENANCE_LOCK_RETRY_MIN_MS);
|
|
126
|
+
try {
|
|
127
|
+
await budget.wait(Math.min(jitter, remaining), budget.signal);
|
|
128
|
+
}
|
|
129
|
+
catch (waitError) {
|
|
130
|
+
assertNotCancelled(projectId, budget.signal);
|
|
131
|
+
throw waitError;
|
|
132
|
+
}
|
|
77
133
|
}
|
|
78
134
|
}
|
|
79
135
|
}
|
|
@@ -108,11 +164,12 @@ function releaseOwnedProjectMaintenanceLock(lock, ownerIdentity) {
|
|
|
108
164
|
* including ones this process already holds: callers that need nesting must
|
|
109
165
|
* use an already-locked private path instead of acquiring twice.
|
|
110
166
|
*/
|
|
111
|
-
export function acquireProjectMaintenanceLocks(home, projectIds) {
|
|
167
|
+
export async function acquireProjectMaintenanceLocks(home, projectIds, options = {}) {
|
|
168
|
+
const budget = acquisitionBudget(options);
|
|
112
169
|
const releases = [];
|
|
113
170
|
try {
|
|
114
171
|
for (const projectId of [...new Set(projectIds)].sort()) {
|
|
115
|
-
releases.push(
|
|
172
|
+
releases.push(await acquireWithBudget(home, projectId, budget, releases.length === 0));
|
|
116
173
|
}
|
|
117
174
|
}
|
|
118
175
|
catch (error) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { isArchivePersistenceFailure, recordArchiveCleanup } from "../task/archiveDiagnostics.js";
|
|
4
|
+
import { projectTaskRemoteDeliveryFromStore } from "../commands/taskRemoteDeliveryCommand.js";
|
|
5
|
+
import { WorkItemChangeSetManager } from "../workspace/workItemChangeSetManager.js";
|
|
6
|
+
import { archiveSettlementChecks, archiveExecutionChecks } from "../task/archivePreflight.js";
|
|
7
|
+
import { CleanupInspectionError } from "../workspace/cleanupInspection.js";
|
|
2
8
|
import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
3
9
|
import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
|
|
4
10
|
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
@@ -81,8 +87,12 @@ export class TaskWorkspaceCoordinator {
|
|
|
81
87
|
.map(({ projectId }) => projectId);
|
|
82
88
|
const releaseMaintenance = projectIds.length === 0
|
|
83
89
|
? () => { }
|
|
84
|
-
: acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
90
|
+
: await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
85
91
|
try {
|
|
92
|
+
if (!isDeepStrictEqual(this.store.getWorkItem(taskId, workItemId), item)
|
|
93
|
+
|| !isDeepStrictEqual(this.store.getWorkItemWorkspace(taskId, workItemId), workspace)) {
|
|
94
|
+
throw new Error(`Work item changed while waiting for cleanup: ${taskId}/${workItemId}.`);
|
|
95
|
+
}
|
|
86
96
|
const state = await this.preparer.inspectWorkItemWorkspace(item.taskId, item.id);
|
|
87
97
|
if (state === "dirty")
|
|
88
98
|
return "dirty";
|
|
@@ -92,7 +102,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
92
102
|
const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForWorkItem(item.taskId, item.id);
|
|
93
103
|
if (laneCleanup === "dirty")
|
|
94
104
|
return "dirty";
|
|
95
|
-
return this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
|
|
105
|
+
return await this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
|
|
96
106
|
}
|
|
97
107
|
finally {
|
|
98
108
|
releaseMaintenance();
|
|
@@ -133,8 +143,12 @@ export class TaskWorkspaceCoordinator {
|
|
|
133
143
|
.map(({ projectId }) => projectId);
|
|
134
144
|
const releaseMaintenance = projectIds.length === 0
|
|
135
145
|
? () => { }
|
|
136
|
-
: acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
146
|
+
: await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
137
147
|
try {
|
|
148
|
+
if (!isDeepStrictEqual(this.store.getReviewRound(taskId, reviewRoundId), round)
|
|
149
|
+
|| !isDeepStrictEqual(this.store.getReviewRoundWorkspace(taskId, reviewRoundId), workspace)) {
|
|
150
|
+
throw new Error(`ReviewRound changed while waiting for cleanup: ${taskId}/${reviewRoundId}.`);
|
|
151
|
+
}
|
|
138
152
|
const state = await this.preparer.inspectReviewRoundWorkspace(taskId, reviewRoundId);
|
|
139
153
|
if (state === "dirty")
|
|
140
154
|
return "dirty";
|
|
@@ -142,7 +156,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
142
156
|
const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId);
|
|
143
157
|
if (laneCleanup === "dirty")
|
|
144
158
|
return "dirty";
|
|
145
|
-
return this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
|
|
159
|
+
return await this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
|
|
146
160
|
}
|
|
147
161
|
finally {
|
|
148
162
|
releaseMaintenance();
|
|
@@ -157,6 +171,9 @@ export class TaskWorkspaceCoordinator {
|
|
|
157
171
|
if (task.status !== "completed" && task.status !== "cancelled") {
|
|
158
172
|
throw new Error(`Task must be completed or retired before archive cleanup: ${task.id}.`);
|
|
159
173
|
}
|
|
174
|
+
const settlement = [...archiveSettlementChecks(this.store, task), ...archiveExecutionChecks(this.store, taskId)];
|
|
175
|
+
if (settlement.length > 0)
|
|
176
|
+
throw new CleanupInspectionError(settlement);
|
|
160
177
|
const managedWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
|
|
161
178
|
.sort((left, right) => managedWorkspaceKey(left.owner)
|
|
162
179
|
.localeCompare(managedWorkspaceKey(right.owner)));
|
|
@@ -168,7 +185,14 @@ export class TaskWorkspaceCoordinator {
|
|
|
168
185
|
for (const entry of workspace.entries)
|
|
169
186
|
projectIds.add(entry.projectId);
|
|
170
187
|
}
|
|
171
|
-
releaseMaintenance = acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
188
|
+
releaseMaintenance = await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
189
|
+
this.#assertTaskArchiveLifecycle(task);
|
|
190
|
+
const currentWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
|
|
191
|
+
.sort((left, right) => managedWorkspaceKey(left.owner)
|
|
192
|
+
.localeCompare(managedWorkspaceKey(right.owner)));
|
|
193
|
+
if (!isDeepStrictEqual(currentWorkspaces, managedWorkspaces)) {
|
|
194
|
+
throw new Error(`Task workspaces changed while waiting for archive cleanup: ${taskId}.`);
|
|
195
|
+
}
|
|
172
196
|
const laneWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "execution-lane");
|
|
173
197
|
const integrationWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "integration-attempt");
|
|
174
198
|
const workspaces = managedWorkspaces
|
|
@@ -196,89 +220,19 @@ export class TaskWorkspaceCoordinator {
|
|
|
196
220
|
workItems: allWorkItems,
|
|
197
221
|
reviewRounds: allReviewRounds
|
|
198
222
|
};
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
reason: "dirty-worktree",
|
|
210
|
-
resource: `review-round:${task.id}/${round.id}`,
|
|
211
|
-
retryable: true
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
for (const item of workItems) {
|
|
216
|
-
if (await this.preparer.inspectWorkItemWorkspace(task.id, item.id) === "dirty") {
|
|
217
|
-
return {
|
|
218
|
-
taskId,
|
|
219
|
-
status: "retained-dirty",
|
|
220
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
221
|
-
error: `WorkItem worktree is dirty: ${item.id}.`,
|
|
222
|
-
reason: "dirty-worktree",
|
|
223
|
-
resource: `work-item:${task.id}/${item.id}`,
|
|
224
|
-
retryable: true
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
for (const workspace of laneWorkspaces) {
|
|
229
|
-
if (workspace.owner.type !== "execution-lane")
|
|
230
|
-
continue;
|
|
231
|
-
if (await this.preparer.inspectExecutionLaneWorkspace(task.id, workspace.owner.executionGroupId, workspace.owner.executionLaneId) === "dirty") {
|
|
232
|
-
return {
|
|
233
|
-
taskId,
|
|
234
|
-
status: "retained-dirty",
|
|
235
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
236
|
-
error: `Execution Lane worktree is dirty: ${managedWorkspaceKey(workspace.owner)}.`,
|
|
237
|
-
reason: "dirty-worktree",
|
|
238
|
-
resource: managedWorkspaceKey(workspace.owner),
|
|
239
|
-
retryable: true
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
for (const workspace of integrationWorkspaces) {
|
|
244
|
-
if (workspace.owner.type !== "integration-attempt")
|
|
245
|
-
continue;
|
|
246
|
-
const attempt = this.store.getIntegrationAttempt(task.id, workspace.owner.integrationAttemptId);
|
|
247
|
-
if (attempt === null
|
|
248
|
-
|| attempt.status === "running"
|
|
249
|
-
|| attempt.status === "blocked"
|
|
250
|
-
|| attempt.status === "validating") {
|
|
251
|
-
throw new Error(`IntegrationAttempt must be terminal before archive cleanup: ${workspace.owner.integrationAttemptId}.`);
|
|
252
|
-
}
|
|
253
|
-
if (await this.preparer.inspectIntegrationWorkspace(task.id, attempt.id) === "dirty") {
|
|
254
|
-
return {
|
|
255
|
-
taskId,
|
|
256
|
-
status: "retained-dirty",
|
|
257
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
258
|
-
error: `Integration worktree is dirty: ${attempt.id}.`,
|
|
259
|
-
reason: "dirty-worktree",
|
|
260
|
-
resource: `integration-attempt:${task.id}/${attempt.id}`,
|
|
261
|
-
retryable: true
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
const state = await this.preparer.inspectTaskMainWorkspace(taskId);
|
|
266
|
-
if (state === "dirty") {
|
|
267
|
-
return {
|
|
268
|
-
taskId,
|
|
269
|
-
status: "retained-dirty",
|
|
270
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
271
|
-
error: `Task main worktree is dirty: ${task.id}.`,
|
|
272
|
-
reason: "dirty-worktree",
|
|
273
|
-
resource: `task-worktree:${task.id}`,
|
|
274
|
-
retryable: true
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
const activeRole = this.store.listRoles(taskId)
|
|
278
|
-
.find((role) => this.store.getActiveRun(taskId, role.name) !== null);
|
|
279
|
-
if (activeRole !== undefined) {
|
|
280
|
-
throw new WorkspaceCleanupBlockedError("active-turn", `role:${task.id}/${activeRole.name}`, true, `Task Role still has an active AgentRun: ${task.id}/${activeRole.name}.`);
|
|
223
|
+
const checks = [];
|
|
224
|
+
for (const workspace of managedWorkspaces) {
|
|
225
|
+
const workspaceDisposition = workspace.owner.type === "work-item"
|
|
226
|
+
&& this.store.getWorkItem(task.id, workspace.owner.workItemId)?.status === "retired" ? "abandoned" : disposition;
|
|
227
|
+
const inspected = await this.preparer.inspectWorkspaceCleanup(workspace, workspaceDisposition);
|
|
228
|
+
// Child worktrees are removed first below. Their Git registrations
|
|
229
|
+
// necessarily exist now; the clone's removal boundary rechecks them
|
|
230
|
+
// after every child cleanup and never removes an unresolved owner.
|
|
231
|
+
checks.push(...inspected.filter(c => !(workspace.owner.type === "task"
|
|
232
|
+
&& c.reason === "git-worktree-registrations")));
|
|
281
233
|
}
|
|
234
|
+
if (checks.length > 0)
|
|
235
|
+
throw new CleanupInspectionError(checks);
|
|
282
236
|
const roleNames = this.store.listRoles(taskId).map(({ name }) => name);
|
|
283
237
|
await this.#stopLiveRoles(taskId, roleNames);
|
|
284
238
|
await this.runtime.assertTaskPhysicalResourcesReleased?.(task.id);
|
|
@@ -292,7 +246,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
292
246
|
for (const workspace of integrationWorkspaces) {
|
|
293
247
|
this.#assertTaskArchiveLifecycle(task);
|
|
294
248
|
if (workspace.owner.type === "integration-attempt") {
|
|
295
|
-
await this.preparer.cleanupIntegrationWorkspace(task.id, workspace.owner.integrationAttemptId);
|
|
249
|
+
await this.preparer.cleanupIntegrationWorkspace(task.id, workspace.owner.integrationAttemptId, { preserveChanges: true });
|
|
296
250
|
}
|
|
297
251
|
}
|
|
298
252
|
for (const round of reviewRounds) {
|
|
@@ -304,32 +258,159 @@ export class TaskWorkspaceCoordinator {
|
|
|
304
258
|
await this.preparer.cleanupWorkItemWorkspace(task.id, item.id, item.status === "accepted" ? disposition : "abandoned");
|
|
305
259
|
}
|
|
306
260
|
this.#assertTaskArchiveLifecycle(task);
|
|
307
|
-
return this.preparer.cleanupTaskForArchive(taskId);
|
|
261
|
+
return await this.preparer.cleanupTaskForArchive(taskId, disposition);
|
|
308
262
|
}
|
|
309
263
|
catch (error) {
|
|
310
264
|
const task = this.store.getTask(taskId);
|
|
311
265
|
return {
|
|
312
266
|
taskId,
|
|
313
|
-
status: "
|
|
267
|
+
status: error instanceof CleanupInspectionError && error.checks.every(c => c.reason === "dirty-worktree")
|
|
268
|
+
? "retained-dirty" : "failed",
|
|
314
269
|
...(task?.cwd === undefined ? {} : { path: task.cwd }),
|
|
315
270
|
error: error instanceof Error ? error.message : String(error),
|
|
316
|
-
...(error instanceof
|
|
317
|
-
? {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
271
|
+
...(error instanceof CleanupInspectionError
|
|
272
|
+
? { checks: error.checks, reason: error.checks[0]?.reason ?? "cleanup-failed",
|
|
273
|
+
resource: error.checks[0]?.resource ?? `task:${taskId}`, retryable: true }
|
|
274
|
+
: error instanceof WorkspaceCleanupBlockedError
|
|
275
|
+
? {
|
|
276
|
+
reason: error.reason,
|
|
277
|
+
resource: error.resource,
|
|
278
|
+
retryable: error.retryable
|
|
279
|
+
}
|
|
280
|
+
: {
|
|
281
|
+
reason: "cleanup-failed",
|
|
282
|
+
resource: `task:${taskId}`,
|
|
283
|
+
retryable: true
|
|
284
|
+
})
|
|
327
285
|
};
|
|
328
286
|
}
|
|
329
287
|
finally {
|
|
330
288
|
releaseMaintenance?.();
|
|
331
289
|
}
|
|
332
290
|
}
|
|
291
|
+
/** Best-effort cleanup only after explicit force admission has committed.
|
|
292
|
+
* This is one foreground attempt, not a retry worker. Each independent result
|
|
293
|
+
* is durable before continuing, and a failed audit write really fails.
|
|
294
|
+
*/
|
|
295
|
+
async cleanupArchivedTask(taskId, disposition) {
|
|
296
|
+
const task = this.store.getTask(taskId);
|
|
297
|
+
if (task?.status !== "archived"
|
|
298
|
+
|| !this.store.listEvents(taskId).some(e => e.type === "task.archived" && e.payload.force === "true")) {
|
|
299
|
+
throw new Error(`Force archive must commit before cleanup: ${taskId}.`);
|
|
300
|
+
}
|
|
301
|
+
const attempt = async (diagnostic, action) => {
|
|
302
|
+
recordArchiveCleanup(this.store, taskId, diagnostic, "started");
|
|
303
|
+
let status;
|
|
304
|
+
let detail = diagnostic.detail;
|
|
305
|
+
try {
|
|
306
|
+
const result = await action();
|
|
307
|
+
status = result === "dirty" ? "retained" : result;
|
|
308
|
+
if (result === "dirty")
|
|
309
|
+
detail = "Dirty workspace retained; force archive does not discard local changes.";
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (isArchivePersistenceFailure(error))
|
|
313
|
+
throw error;
|
|
314
|
+
status = "retained";
|
|
315
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
316
|
+
}
|
|
317
|
+
recordArchiveCleanup(this.store, taskId, { ...diagnostic, detail }, status);
|
|
318
|
+
return status !== "retained";
|
|
319
|
+
};
|
|
320
|
+
let runtimeReleased = true;
|
|
321
|
+
for (const role of this.store.listRoles(taskId)) {
|
|
322
|
+
const released = await attempt({ resource: `role:${taskId}/${role.name}`,
|
|
323
|
+
detail: "Exact Role runtime stop." }, async () => {
|
|
324
|
+
const checks = archiveExecutionChecks(this.store, taskId).filter(c => c.resource === `role:${taskId}/${role.name}`);
|
|
325
|
+
if (checks.length > 0)
|
|
326
|
+
throw new CleanupInspectionError(checks);
|
|
327
|
+
await this.#stopLiveRoles(taskId, [role.name]);
|
|
328
|
+
return "released";
|
|
329
|
+
});
|
|
330
|
+
runtimeReleased &&= released;
|
|
331
|
+
}
|
|
332
|
+
const physicalReleased = await attempt({ resource: `runtime:${taskId}`,
|
|
333
|
+
detail: "Verify exact physical resource release before workspace deletion." }, async () => {
|
|
334
|
+
const checks = archiveExecutionChecks(this.store, taskId);
|
|
335
|
+
if (checks.length > 0)
|
|
336
|
+
throw new CleanupInspectionError(checks);
|
|
337
|
+
if (!runtimeReleased)
|
|
338
|
+
throw new Error("One or more Role runtimes could not be safely released.");
|
|
339
|
+
if (this.runtime.assertTaskPhysicalResourcesReleased === undefined) {
|
|
340
|
+
throw new Error("Physical resource release inspection is unavailable; workspace references retained.");
|
|
341
|
+
}
|
|
342
|
+
await this.runtime.assertTaskPhysicalResourcesReleased(taskId);
|
|
343
|
+
return "released";
|
|
344
|
+
});
|
|
345
|
+
if (physicalReleased) {
|
|
346
|
+
// Task main owns the Git objects of its dependent worktrees, so it is
|
|
347
|
+
// always last and remains when any dependent cleanup is unresolved.
|
|
348
|
+
const workspaces = this.store.listManagedWorkspaces(taskId).sort((a, b) => Number(a.owner.type === "task") - Number(b.owner.type === "task"));
|
|
349
|
+
for (const workspace of workspaces) {
|
|
350
|
+
await attempt({ resource: managedWorkspaceKey(workspace.owner),
|
|
351
|
+
detail: "Remove only clean, exactly owned workspace resources.",
|
|
352
|
+
paths: [workspace.root, ...workspace.entries.filter(e => e.access === "write").map(e => e.path)]
|
|
353
|
+
}, async () => {
|
|
354
|
+
const release = await acquireProjectMaintenanceLocks(this.preparer.home, workspace.entries.map(e => e.projectId));
|
|
355
|
+
try {
|
|
356
|
+
if (!isDeepStrictEqual(this.store.getManagedWorkspace(workspace.owner), workspace)) {
|
|
357
|
+
throw new Error("Workspace ownership changed; retained.");
|
|
358
|
+
}
|
|
359
|
+
const owner = workspace.owner;
|
|
360
|
+
switch (owner.type) {
|
|
361
|
+
case "work-item": {
|
|
362
|
+
const item = this.store.getWorkItem(taskId, owner.workItemId);
|
|
363
|
+
if (item?.status === "accepted" && disposition === "integrated") {
|
|
364
|
+
await new WorkItemChangeSetManager(this.store).assertIntegrated(taskId, item.id);
|
|
365
|
+
}
|
|
366
|
+
return await this.preparer.cleanupWorkItemWorkspace(taskId, owner.workItemId, item?.status === "retired" ? "abandoned" : disposition);
|
|
367
|
+
}
|
|
368
|
+
case "review-round":
|
|
369
|
+
return await this.preparer.cleanupReviewRoundWorkspace(taskId, owner.reviewRoundId);
|
|
370
|
+
case "execution-lane":
|
|
371
|
+
return await this.preparer.cleanupExecutionLaneWorkspace(taskId, owner.executionGroupId, owner.executionLaneId);
|
|
372
|
+
case "integration-attempt":
|
|
373
|
+
if (await this.preparer.inspectIntegrationWorkspace(taskId, owner.integrationAttemptId) === "dirty")
|
|
374
|
+
return "dirty";
|
|
375
|
+
return await this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId, { preserveChanges: true });
|
|
376
|
+
case "task": {
|
|
377
|
+
const current = this.store.getTask(taskId);
|
|
378
|
+
const delivery = projectTaskRemoteDeliveryFromStore(this.store, current);
|
|
379
|
+
if (!delivery.allMerged || !delivery.allVerified) {
|
|
380
|
+
throw new Error("Task main retained: local commits are not covered by verified remote delivery. Force does not discard them.");
|
|
381
|
+
}
|
|
382
|
+
for (const entry of workspace.entries.filter(e => e.access === "write")) {
|
|
383
|
+
const covered = delivery.projects.find(p => p.projectId === entry.projectId);
|
|
384
|
+
const expected = covered?.expectedLocalCommit;
|
|
385
|
+
const path = await lstat(entry.path).catch(error => {
|
|
386
|
+
if (error.code === "ENOENT")
|
|
387
|
+
return null;
|
|
388
|
+
throw error;
|
|
389
|
+
});
|
|
390
|
+
if (expected !== null && expected !== undefined
|
|
391
|
+
&& path !== null) {
|
|
392
|
+
const actual = await this.preparer.git.inspect(entry.path, "HEAD");
|
|
393
|
+
if (actual.baseCommit !== expected && actual.baseCommit !== covered?.deliveryLocalCommit) {
|
|
394
|
+
throw new Error(`Task main HEAD changed: ${entry.path}; retained.`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const result = await this.preparer.cleanupTaskForArchive(taskId, disposition);
|
|
399
|
+
if (result.status !== "removed")
|
|
400
|
+
throw new Error(result.error ?? "Task main or dependent workspace retained.");
|
|
401
|
+
return "removed";
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
release();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
recordArchiveCleanup(this.store, taskId, { resource: `task:${taskId}`,
|
|
412
|
+
detail: "Foreground cleanup attempt finished; inspect retained resource references and warnings." }, "finished");
|
|
413
|
+
}
|
|
333
414
|
#assertTaskArchiveSnapshot(snapshot) {
|
|
334
415
|
this.#assertTaskArchiveLifecycle(snapshot.task);
|
|
335
416
|
const managedWorkspaces = [...this.store.listManagedWorkspaces(snapshot.task.id)]
|