@zq-silk/yui 0.15.11 → 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/agentRun/agentRun.js +3 -0
- package/dist/cli/commandCatalog.js +38 -10
- package/dist/cli/interactionPolicy.js +4 -0
- package/dist/cli/managedDiagnostics.js +1 -1
- package/dist/cli.js +46 -15
- package/dist/commands/executionAuditCommands.js +10 -0
- package/dist/commands/globalRoleCommands.js +27 -2
- package/dist/commands/projectCommands.js +44 -15
- package/dist/commands/taskCommands.js +95 -33
- package/dist/commands/taskIntegrationCommands.js +3 -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 +18 -7
- package/dist/context/runContextPack.js +3 -0
- package/dist/context/taskCatalog.js +187 -0
- package/dist/context/taskContext.js +17 -4
- package/dist/controller/controller.js +11 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +80 -19
- package/dist/controller/globalInputDelivery.js +13 -0
- package/dist/controller/providerRetryAdmission.js +100 -0
- package/dist/controller/providerRetryDelivery.js +218 -0
- package/dist/controller/runtime.js +36 -1
- package/dist/integration/gitIntegrationService.js +19 -6
- package/dist/lifecycle/exactRunTerminalization.js +4 -1
- package/dist/message/globalProviderRetry.js +15 -0
- package/dist/observability/executionAudit.js +19 -0
- package/dist/repository/gitWorkspace.js +371 -105
- package/dist/repository/projectMaintenanceLock.js +75 -18
- package/dist/repository/taskWorkspaceCoordinator.js +71 -124
- package/dist/repository/taskWorkspacePreparer.js +86 -24
- package/dist/repository/workspaceCleanupInspection.js +187 -0
- package/dist/runtime/agentError.js +5 -3
- package/dist/runtime/agentHost.js +28 -11
- package/dist/runtime/builtinAgentErrorMappers.js +91 -0
- package/dist/runtime/codexAppServerRuntime.js +34 -3
- package/dist/runtime/providerControl.js +5 -1
- package/dist/runtime/providerRetry.js +198 -0
- package/dist/runtime/providerRuntimeIdentity.js +28 -2
- package/dist/runtime/sessionTokenMetrics.js +15 -5
- package/dist/runtime/structuredProviderHost.js +6 -2
- package/dist/runtime/taskUsageMetrics.js +275 -0
- package/dist/scheduler/activeRoleRunDelivery.js +12 -0
- package/dist/scheduler/leaderWakeupProcessor.js +5 -0
- package/dist/scheduler/taskExecutionProjection.js +26 -5
- package/dist/scheduler/taskObservabilityProjection.js +6 -44
- package/dist/storage/sqliteSchema.js +31 -0
- package/dist/storage/sqliteStore.js +17 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -0
- package/dist/storage/taskCatalog.js +123 -0
- package/dist/storage/taskStore.js +2 -0
- package/dist/task/archiveDiagnostics.js +1 -0
- package/dist/task/archivePreflight.js +124 -0
- 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 +92 -17
- package/dist/web/assets/client/components.js +55 -13
- package/dist/web/assets/client/i18n.js +72 -4
- package/dist/web/assets/client/taskSurface.js +2 -1
- package/dist/web/assets/client/view.js +35 -7
- package/dist/web/assets/shell.js +6 -0
- package/dist/web/assets/styles/layout.js +7 -0
- package/dist/web/webServer.js +14 -3
- package/dist/web/webSnapshot.js +12 -3
- package/dist/workspace/cleanupInspection.js +63 -0
- package/dist/workspace/workItemChangeSetManager.js +110 -50
- 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/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 +16 -0
- package/docs/testing/verification-levels.zh-CN.md +13 -1
- package/i18n/README.zh-CN.md +13 -7
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +3 -2
- package/skills/yui-operator/SKILL.md +13 -2
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-runtime/SKILL.md +17 -2
- package/skills/yui-runtime/references/publication.md +26 -4
- 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) {
|
|
@@ -3,6 +3,8 @@ import { lstat } from "node:fs/promises";
|
|
|
3
3
|
import { isArchivePersistenceFailure, recordArchiveCleanup } from "../task/archiveDiagnostics.js";
|
|
4
4
|
import { projectTaskRemoteDeliveryFromStore } from "../commands/taskRemoteDeliveryCommand.js";
|
|
5
5
|
import { WorkItemChangeSetManager } from "../workspace/workItemChangeSetManager.js";
|
|
6
|
+
import { archiveSettlementChecks, archiveExecutionChecks } from "../task/archivePreflight.js";
|
|
7
|
+
import { CleanupInspectionError } from "../workspace/cleanupInspection.js";
|
|
6
8
|
import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
7
9
|
import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
|
|
8
10
|
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
@@ -85,8 +87,12 @@ export class TaskWorkspaceCoordinator {
|
|
|
85
87
|
.map(({ projectId }) => projectId);
|
|
86
88
|
const releaseMaintenance = projectIds.length === 0
|
|
87
89
|
? () => { }
|
|
88
|
-
: acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
90
|
+
: await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
89
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
|
+
}
|
|
90
96
|
const state = await this.preparer.inspectWorkItemWorkspace(item.taskId, item.id);
|
|
91
97
|
if (state === "dirty")
|
|
92
98
|
return "dirty";
|
|
@@ -96,7 +102,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
96
102
|
const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForWorkItem(item.taskId, item.id);
|
|
97
103
|
if (laneCleanup === "dirty")
|
|
98
104
|
return "dirty";
|
|
99
|
-
return this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
|
|
105
|
+
return await this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
|
|
100
106
|
}
|
|
101
107
|
finally {
|
|
102
108
|
releaseMaintenance();
|
|
@@ -137,8 +143,12 @@ export class TaskWorkspaceCoordinator {
|
|
|
137
143
|
.map(({ projectId }) => projectId);
|
|
138
144
|
const releaseMaintenance = projectIds.length === 0
|
|
139
145
|
? () => { }
|
|
140
|
-
: acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
146
|
+
: await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
141
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
|
+
}
|
|
142
152
|
const state = await this.preparer.inspectReviewRoundWorkspace(taskId, reviewRoundId);
|
|
143
153
|
if (state === "dirty")
|
|
144
154
|
return "dirty";
|
|
@@ -146,7 +156,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
146
156
|
const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId);
|
|
147
157
|
if (laneCleanup === "dirty")
|
|
148
158
|
return "dirty";
|
|
149
|
-
return this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
|
|
159
|
+
return await this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
|
|
150
160
|
}
|
|
151
161
|
finally {
|
|
152
162
|
releaseMaintenance();
|
|
@@ -161,6 +171,9 @@ export class TaskWorkspaceCoordinator {
|
|
|
161
171
|
if (task.status !== "completed" && task.status !== "cancelled") {
|
|
162
172
|
throw new Error(`Task must be completed or retired before archive cleanup: ${task.id}.`);
|
|
163
173
|
}
|
|
174
|
+
const settlement = [...archiveSettlementChecks(this.store, task), ...archiveExecutionChecks(this.store, taskId)];
|
|
175
|
+
if (settlement.length > 0)
|
|
176
|
+
throw new CleanupInspectionError(settlement);
|
|
164
177
|
const managedWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
|
|
165
178
|
.sort((left, right) => managedWorkspaceKey(left.owner)
|
|
166
179
|
.localeCompare(managedWorkspaceKey(right.owner)));
|
|
@@ -172,7 +185,14 @@ export class TaskWorkspaceCoordinator {
|
|
|
172
185
|
for (const entry of workspace.entries)
|
|
173
186
|
projectIds.add(entry.projectId);
|
|
174
187
|
}
|
|
175
|
-
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
|
+
}
|
|
176
196
|
const laneWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "execution-lane");
|
|
177
197
|
const integrationWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "integration-attempt");
|
|
178
198
|
const workspaces = managedWorkspaces
|
|
@@ -200,90 +220,19 @@ export class TaskWorkspaceCoordinator {
|
|
|
200
220
|
workItems: allWorkItems,
|
|
201
221
|
reviewRounds: allReviewRounds
|
|
202
222
|
};
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
reason: "dirty-worktree",
|
|
214
|
-
resource: `review-round:${task.id}/${round.id}`,
|
|
215
|
-
retryable: true
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
for (const item of workItems) {
|
|
220
|
-
if (await this.preparer.inspectWorkItemWorkspace(task.id, item.id) === "dirty") {
|
|
221
|
-
return {
|
|
222
|
-
taskId,
|
|
223
|
-
status: "retained-dirty",
|
|
224
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
225
|
-
error: `WorkItem worktree is dirty: ${item.id}.`,
|
|
226
|
-
reason: "dirty-worktree",
|
|
227
|
-
resource: `work-item:${task.id}/${item.id}`,
|
|
228
|
-
retryable: true
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
for (const workspace of laneWorkspaces) {
|
|
233
|
-
if (workspace.owner.type !== "execution-lane")
|
|
234
|
-
continue;
|
|
235
|
-
if (await this.preparer.inspectExecutionLaneWorkspace(task.id, workspace.owner.executionGroupId, workspace.owner.executionLaneId) === "dirty") {
|
|
236
|
-
return {
|
|
237
|
-
taskId,
|
|
238
|
-
status: "retained-dirty",
|
|
239
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
240
|
-
error: `Execution Lane worktree is dirty: ${managedWorkspaceKey(workspace.owner)}.`,
|
|
241
|
-
reason: "dirty-worktree",
|
|
242
|
-
resource: managedWorkspaceKey(workspace.owner),
|
|
243
|
-
retryable: true
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
for (const workspace of integrationWorkspaces) {
|
|
248
|
-
if (workspace.owner.type !== "integration-attempt")
|
|
249
|
-
continue;
|
|
250
|
-
const attempt = this.store.getIntegrationAttempt(task.id, workspace.owner.integrationAttemptId);
|
|
251
|
-
if (attempt === null
|
|
252
|
-
|| attempt.status === "running"
|
|
253
|
-
|| attempt.status === "blocked"
|
|
254
|
-
|| attempt.status === "conflicted"
|
|
255
|
-
|| attempt.status === "validating") {
|
|
256
|
-
throw new Error(`IntegrationAttempt must be terminal before archive cleanup: ${workspace.owner.integrationAttemptId}.`);
|
|
257
|
-
}
|
|
258
|
-
if (await this.preparer.inspectIntegrationWorkspace(task.id, attempt.id) === "dirty") {
|
|
259
|
-
return {
|
|
260
|
-
taskId,
|
|
261
|
-
status: "retained-dirty",
|
|
262
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
263
|
-
error: `Integration worktree is dirty: ${attempt.id}.`,
|
|
264
|
-
reason: "dirty-worktree",
|
|
265
|
-
resource: `integration-attempt:${task.id}/${attempt.id}`,
|
|
266
|
-
retryable: true
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
const state = await this.preparer.inspectTaskMainWorkspace(taskId);
|
|
271
|
-
if (state === "dirty") {
|
|
272
|
-
return {
|
|
273
|
-
taskId,
|
|
274
|
-
status: "retained-dirty",
|
|
275
|
-
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
276
|
-
error: `Task main worktree is dirty: ${task.id}.`,
|
|
277
|
-
reason: "dirty-worktree",
|
|
278
|
-
resource: `task-worktree:${task.id}`,
|
|
279
|
-
retryable: true
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
const activeRole = this.store.listRoles(taskId)
|
|
283
|
-
.find((role) => this.store.getActiveRun(taskId, role.name) !== null);
|
|
284
|
-
if (activeRole !== undefined) {
|
|
285
|
-
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")));
|
|
286
233
|
}
|
|
234
|
+
if (checks.length > 0)
|
|
235
|
+
throw new CleanupInspectionError(checks);
|
|
287
236
|
const roleNames = this.store.listRoles(taskId).map(({ name }) => name);
|
|
288
237
|
await this.#stopLiveRoles(taskId, roleNames);
|
|
289
238
|
await this.runtime.assertTaskPhysicalResourcesReleased?.(task.id);
|
|
@@ -297,7 +246,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
297
246
|
for (const workspace of integrationWorkspaces) {
|
|
298
247
|
this.#assertTaskArchiveLifecycle(task);
|
|
299
248
|
if (workspace.owner.type === "integration-attempt") {
|
|
300
|
-
await this.preparer.cleanupIntegrationWorkspace(task.id, workspace.owner.integrationAttemptId);
|
|
249
|
+
await this.preparer.cleanupIntegrationWorkspace(task.id, workspace.owner.integrationAttemptId, { preserveChanges: true });
|
|
301
250
|
}
|
|
302
251
|
}
|
|
303
252
|
for (const round of reviewRounds) {
|
|
@@ -309,26 +258,30 @@ export class TaskWorkspaceCoordinator {
|
|
|
309
258
|
await this.preparer.cleanupWorkItemWorkspace(task.id, item.id, item.status === "accepted" ? disposition : "abandoned");
|
|
310
259
|
}
|
|
311
260
|
this.#assertTaskArchiveLifecycle(task);
|
|
312
|
-
return this.preparer.cleanupTaskForArchive(taskId);
|
|
261
|
+
return await this.preparer.cleanupTaskForArchive(taskId, disposition);
|
|
313
262
|
}
|
|
314
263
|
catch (error) {
|
|
315
264
|
const task = this.store.getTask(taskId);
|
|
316
265
|
return {
|
|
317
266
|
taskId,
|
|
318
|
-
status: "
|
|
267
|
+
status: error instanceof CleanupInspectionError && error.checks.every(c => c.reason === "dirty-worktree")
|
|
268
|
+
? "retained-dirty" : "failed",
|
|
319
269
|
...(task?.cwd === undefined ? {} : { path: task.cwd }),
|
|
320
270
|
error: error instanceof Error ? error.message : String(error),
|
|
321
|
-
...(error instanceof
|
|
322
|
-
? {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
+
})
|
|
332
285
|
};
|
|
333
286
|
}
|
|
334
287
|
finally {
|
|
@@ -368,15 +321,9 @@ export class TaskWorkspaceCoordinator {
|
|
|
368
321
|
for (const role of this.store.listRoles(taskId)) {
|
|
369
322
|
const released = await attempt({ resource: `role:${taskId}/${role.name}`,
|
|
370
323
|
detail: "Exact Role runtime stop." }, async () => {
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|| !["completed", "failed", "cancelled"].includes(provider.run.status))) {
|
|
375
|
-
throw new Error(`Original mailbox claim ${mailbox.processing.batchId} retained; execution outcome is not known.`);
|
|
376
|
-
}
|
|
377
|
-
if (provider?.run != null && !["completed", "failed", "cancelled"].includes(provider.run.status)) {
|
|
378
|
-
throw new Error(`Provider input ${provider.run.attemptId}/${provider.run.status} retained; establish exact quiescence before resolving it.`);
|
|
379
|
-
}
|
|
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);
|
|
380
327
|
await this.#stopLiveRoles(taskId, [role.name]);
|
|
381
328
|
return "released";
|
|
382
329
|
});
|
|
@@ -384,11 +331,9 @@ export class TaskWorkspaceCoordinator {
|
|
|
384
331
|
}
|
|
385
332
|
const physicalReleased = await attempt({ resource: `runtime:${taskId}`,
|
|
386
333
|
detail: "Verify exact physical resource release before workspace deletion." }, async () => {
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
throw new Error(`Execution resources retained: ${activeRun?.id ?? job?.id}; archive is not proof of quiescence.`);
|
|
391
|
-
}
|
|
334
|
+
const checks = archiveExecutionChecks(this.store, taskId);
|
|
335
|
+
if (checks.length > 0)
|
|
336
|
+
throw new CleanupInspectionError(checks);
|
|
392
337
|
if (!runtimeReleased)
|
|
393
338
|
throw new Error("One or more Role runtimes could not be safely released.");
|
|
394
339
|
if (this.runtime.assertTaskPhysicalResourcesReleased === undefined) {
|
|
@@ -406,7 +351,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
406
351
|
detail: "Remove only clean, exactly owned workspace resources.",
|
|
407
352
|
paths: [workspace.root, ...workspace.entries.filter(e => e.access === "write").map(e => e.path)]
|
|
408
353
|
}, async () => {
|
|
409
|
-
const release = acquireProjectMaintenanceLocks(this.preparer.home, workspace.entries.map(e => e.projectId));
|
|
354
|
+
const release = await acquireProjectMaintenanceLocks(this.preparer.home, workspace.entries.map(e => e.projectId));
|
|
410
355
|
try {
|
|
411
356
|
if (!isDeepStrictEqual(this.store.getManagedWorkspace(workspace.owner), workspace)) {
|
|
412
357
|
throw new Error("Workspace ownership changed; retained.");
|
|
@@ -418,16 +363,16 @@ export class TaskWorkspaceCoordinator {
|
|
|
418
363
|
if (item?.status === "accepted" && disposition === "integrated") {
|
|
419
364
|
await new WorkItemChangeSetManager(this.store).assertIntegrated(taskId, item.id);
|
|
420
365
|
}
|
|
421
|
-
return this.preparer.cleanupWorkItemWorkspace(taskId, owner.workItemId, item?.status === "retired" ? "abandoned" : disposition);
|
|
366
|
+
return await this.preparer.cleanupWorkItemWorkspace(taskId, owner.workItemId, item?.status === "retired" ? "abandoned" : disposition);
|
|
422
367
|
}
|
|
423
368
|
case "review-round":
|
|
424
|
-
return this.preparer.cleanupReviewRoundWorkspace(taskId, owner.reviewRoundId);
|
|
369
|
+
return await this.preparer.cleanupReviewRoundWorkspace(taskId, owner.reviewRoundId);
|
|
425
370
|
case "execution-lane":
|
|
426
|
-
return this.preparer.cleanupExecutionLaneWorkspace(taskId, owner.executionGroupId, owner.executionLaneId);
|
|
371
|
+
return await this.preparer.cleanupExecutionLaneWorkspace(taskId, owner.executionGroupId, owner.executionLaneId);
|
|
427
372
|
case "integration-attempt":
|
|
428
373
|
if (await this.preparer.inspectIntegrationWorkspace(taskId, owner.integrationAttemptId) === "dirty")
|
|
429
374
|
return "dirty";
|
|
430
|
-
return this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId);
|
|
375
|
+
return await this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId, { preserveChanges: true });
|
|
431
376
|
case "task": {
|
|
432
377
|
const current = this.store.getTask(taskId);
|
|
433
378
|
const delivery = projectTaskRemoteDeliveryFromStore(this.store, current);
|
|
@@ -435,7 +380,8 @@ export class TaskWorkspaceCoordinator {
|
|
|
435
380
|
throw new Error("Task main retained: local commits are not covered by verified remote delivery. Force does not discard them.");
|
|
436
381
|
}
|
|
437
382
|
for (const entry of workspace.entries.filter(e => e.access === "write")) {
|
|
438
|
-
const
|
|
383
|
+
const covered = delivery.projects.find(p => p.projectId === entry.projectId);
|
|
384
|
+
const expected = covered?.expectedLocalCommit;
|
|
439
385
|
const path = await lstat(entry.path).catch(error => {
|
|
440
386
|
if (error.code === "ENOENT")
|
|
441
387
|
return null;
|
|
@@ -444,11 +390,12 @@ export class TaskWorkspaceCoordinator {
|
|
|
444
390
|
if (expected !== null && expected !== undefined
|
|
445
391
|
&& path !== null) {
|
|
446
392
|
const actual = await this.preparer.git.inspect(entry.path, "HEAD");
|
|
447
|
-
if (actual.baseCommit !== expected)
|
|
393
|
+
if (actual.baseCommit !== expected && actual.baseCommit !== covered?.deliveryLocalCommit) {
|
|
448
394
|
throw new Error(`Task main HEAD changed: ${entry.path}; retained.`);
|
|
395
|
+
}
|
|
449
396
|
}
|
|
450
397
|
}
|
|
451
|
-
const result = await this.preparer.cleanupTaskForArchive(taskId);
|
|
398
|
+
const result = await this.preparer.cleanupTaskForArchive(taskId, disposition);
|
|
452
399
|
if (result.status !== "removed")
|
|
453
400
|
throw new Error(result.error ?? "Task main or dependent workspace retained.");
|
|
454
401
|
return "removed";
|