@zq-silk/yui 0.6.0 → 0.6.2
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/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Per-Project maintenance fence.
|
|
5
|
+
*
|
|
6
|
+
* `project migrate`, Task workspace rebuild, legacy ref archive, and Task
|
|
7
|
+
* archive cleanup are separate CLI processes that all mutate a Project's
|
|
8
|
+
* Git repository. Without coordination they can interleave with each other
|
|
9
|
+
* and with the Controller's worktree preparation. The fence is an exclusive
|
|
10
|
+
* directory under the Home's `locks/` area (O_EXCL via mkdir), so it works
|
|
11
|
+
* for managed and external Projects alike and never touches the Project's
|
|
12
|
+
* own checkout. A crashed holder is reclaimed once its owner process is
|
|
13
|
+
* gone; the owner file records PID + process start time, so a recycled PID
|
|
14
|
+
* can never pass for the original holder.
|
|
15
|
+
*
|
|
16
|
+
* Maintenance operations are long-lived, so a contended fence fails fast
|
|
17
|
+
* with a retryable error instead of blocking the CLI for seconds.
|
|
18
|
+
*/
|
|
19
|
+
const PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS = 250;
|
|
20
|
+
const PROJECT_MAINTENANCE_LOCK_RETRY_MS = 10;
|
|
21
|
+
/** A lock older than this with a dead owner is reclaimed. */
|
|
22
|
+
const STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS = 1_000;
|
|
23
|
+
/**
|
|
24
|
+
* Raised when a Project's maintenance fence is already held. The caller
|
|
25
|
+
* made no changes and is safe to retry once the holder finishes.
|
|
26
|
+
*/
|
|
27
|
+
export class ProjectMaintenanceLockedError extends Error {
|
|
28
|
+
projectId;
|
|
29
|
+
retryable = true;
|
|
30
|
+
constructor(projectId) {
|
|
31
|
+
super(`Project maintenance is already in progress for ${projectId}; retry once it finishes.`);
|
|
32
|
+
this.name = "ProjectMaintenanceLockedError";
|
|
33
|
+
this.projectId = projectId;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Directory of one Project's maintenance fence, below the Home's locks area. */
|
|
37
|
+
export function projectMaintenanceLockPath(home, projectId) {
|
|
38
|
+
return join(home, "locks", "projects", `${projectId}.lock`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Acquire one Project's maintenance fence. Returns the release function;
|
|
42
|
+
* callers MUST release on every exit path (try/finally). A live holder
|
|
43
|
+
* fails fast with {@link ProjectMaintenanceLockedError}; a stale (dead)
|
|
44
|
+
* holder is reclaimed.
|
|
45
|
+
*
|
|
46
|
+
* There is no in-process reentrancy: every acquisition contends, so two
|
|
47
|
+
* independent operations in the same process are mutually exclusive just
|
|
48
|
+
* like two processes. Genuine lexical nesting (an operation that needs the
|
|
49
|
+
* fence while its caller already holds it) goes through private
|
|
50
|
+
* already-locked methods, never through a second acquisition.
|
|
51
|
+
*/
|
|
52
|
+
export function acquireProjectMaintenanceLock(home, projectId) {
|
|
53
|
+
const lock = projectMaintenanceLockPath(home, projectId);
|
|
54
|
+
mkdirSync(join(home, "locks", "projects"), { recursive: true, mode: 0o700 });
|
|
55
|
+
const deadline = Date.now() + PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS;
|
|
56
|
+
while (true) {
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
59
|
+
const ownerIdentity = writeOwnerIdentity(lock);
|
|
60
|
+
let released = false;
|
|
61
|
+
return () => {
|
|
62
|
+
if (released)
|
|
63
|
+
return;
|
|
64
|
+
released = true;
|
|
65
|
+
// Release only the exact acquired instance: a lock that was reclaimed
|
|
66
|
+
// and replaced by a successor after a crash is never deleted by a
|
|
67
|
+
// stale release handle.
|
|
68
|
+
releaseOwnedProjectMaintenanceLock(lock, ownerIdentity);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
73
|
+
throw error;
|
|
74
|
+
reclaimStaleProjectMaintenanceLock(lock);
|
|
75
|
+
if (Date.now() >= deadline)
|
|
76
|
+
throw new ProjectMaintenanceLockedError(projectId);
|
|
77
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, PROJECT_MAINTENANCE_LOCK_RETRY_MS);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Write this process's owner identity and return the exact bytes recorded. */
|
|
82
|
+
function writeOwnerIdentity(lock) {
|
|
83
|
+
const identity = `${process.pid}:${processStartIdentity() ?? ""}`;
|
|
84
|
+
writeFileSync(join(lock, "owner"), `${identity}\n`, { mode: 0o600 });
|
|
85
|
+
return identity;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Remove the lock directory only when its owner file still records the exact
|
|
89
|
+
* identity this handle acquired. A replaced (successor) lock is left intact.
|
|
90
|
+
*/
|
|
91
|
+
function releaseOwnedProjectMaintenanceLock(lock, ownerIdentity) {
|
|
92
|
+
let owner;
|
|
93
|
+
try {
|
|
94
|
+
owner = readFileSync(join(lock, "owner"), "utf8").trim();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (isEnoent(error))
|
|
98
|
+
return; // already gone; nothing to release.
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (owner !== ownerIdentity)
|
|
102
|
+
return;
|
|
103
|
+
rmSync(lock, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Acquire the maintenance fence for several Projects in a stable (sorted)
|
|
107
|
+
* order, so multi-Project maintenance can never deadlock against itself.
|
|
108
|
+
* A failure releases every fence already taken. Every Project contends,
|
|
109
|
+
* including ones this process already holds: callers that need nesting must
|
|
110
|
+
* use an already-locked private path instead of acquiring twice.
|
|
111
|
+
*/
|
|
112
|
+
export function acquireProjectMaintenanceLocks(home, projectIds) {
|
|
113
|
+
const releases = [];
|
|
114
|
+
try {
|
|
115
|
+
for (const projectId of [...new Set(projectIds)].sort()) {
|
|
116
|
+
releases.push(acquireProjectMaintenanceLock(home, projectId));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
for (const release of releases.reverse())
|
|
121
|
+
release();
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
let released = false;
|
|
125
|
+
return () => {
|
|
126
|
+
if (released)
|
|
127
|
+
return;
|
|
128
|
+
released = true;
|
|
129
|
+
for (const release of releases.reverse())
|
|
130
|
+
release();
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Non-blocking fence check for the Controller: a Project is fenced only
|
|
135
|
+
* while a live process holds its lock. A stale (dead-owner) lock reads as
|
|
136
|
+
* unfenced, so a crashed maintenance process never wedges preparation.
|
|
137
|
+
*/
|
|
138
|
+
export function isProjectMaintenanceFenced(home, projectId) {
|
|
139
|
+
const lock = projectMaintenanceLockPath(home, projectId);
|
|
140
|
+
if (!existsSync(lock))
|
|
141
|
+
return false;
|
|
142
|
+
return lockOwnerIsAlive(lock);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Reclaim a stale (dead-owner) lock through a crash-safe compare-and-delete
|
|
146
|
+
* critical section. Two contenders can both observe the same dead owner;
|
|
147
|
+
* without serialization the second could rmSync the successor lock the first
|
|
148
|
+
* created after reclaiming, admitting two holders. A reclaim lock (itself
|
|
149
|
+
* reclaimable) serializes reclaimers, and under it we re-read the owner and
|
|
150
|
+
* delete only when it is still the exact stale instance observed above.
|
|
151
|
+
*/
|
|
152
|
+
function reclaimStaleProjectMaintenanceLock(lock) {
|
|
153
|
+
let expectedOwner;
|
|
154
|
+
try {
|
|
155
|
+
expectedOwner = readFileSync(join(lock, "owner"), "utf8");
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (!isEnoent(error))
|
|
159
|
+
throw error;
|
|
160
|
+
// Ownerless lock: a crash between mkdir and owner publication. Reclaim
|
|
161
|
+
// only when the directory is old enough that the creator is not still
|
|
162
|
+
// initializing; a fresh lock may have its owner file written imminently.
|
|
163
|
+
try {
|
|
164
|
+
if (Date.now() - statSync(lock).mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
catch (statError) {
|
|
168
|
+
if (isEnoent(statError))
|
|
169
|
+
return;
|
|
170
|
+
throw statError;
|
|
171
|
+
}
|
|
172
|
+
expectedOwner = null;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
if (Date.now() - statSync(lock).mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (isEnoent(error))
|
|
180
|
+
return;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (expectedOwner !== null && lockOwnerIsAlive(lock))
|
|
184
|
+
return;
|
|
185
|
+
const reclaimLock = `${lock}.reclaim`;
|
|
186
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
187
|
+
try {
|
|
188
|
+
mkdirSync(reclaimLock, { mode: 0o700 });
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (!isEexist(error))
|
|
192
|
+
throw error;
|
|
193
|
+
// Another reclaimer holds the critical section. Reclaim its lock only
|
|
194
|
+
// when orphaned (dead owner and old enough), then retry once; a live
|
|
195
|
+
// reclaimer will finish and the caller's next O_EXCL mkdir settles it.
|
|
196
|
+
if (reclaimOrphanedReclaimLock(reclaimLock))
|
|
197
|
+
continue;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
writeOwnerIdentity(reclaimLock);
|
|
202
|
+
// Compare-and-delete under the lock: re-read and remove only the exact
|
|
203
|
+
// stale instance observed above. A successor that replaced the lock has
|
|
204
|
+
// different owner bytes (and a fresh mtime) and is never clobbered.
|
|
205
|
+
let stat;
|
|
206
|
+
try {
|
|
207
|
+
stat = statSync(lock);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (isEnoent(error))
|
|
211
|
+
return;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
if (Date.now() - stat.mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
|
|
215
|
+
return;
|
|
216
|
+
let currentOwner;
|
|
217
|
+
try {
|
|
218
|
+
currentOwner = readFileSync(join(lock, "owner"), "utf8");
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (!isEnoent(error))
|
|
222
|
+
throw error;
|
|
223
|
+
// Still ownerless: only reclaim when the initial read was also
|
|
224
|
+
// ownerless. A non-null expectedOwner means another process wrote
|
|
225
|
+
// the owner file between the two reads — leave its lock intact.
|
|
226
|
+
if (expectedOwner !== null)
|
|
227
|
+
return;
|
|
228
|
+
currentOwner = null;
|
|
229
|
+
}
|
|
230
|
+
if (currentOwner !== expectedOwner)
|
|
231
|
+
return;
|
|
232
|
+
if (currentOwner !== null && lockOwnerIsAlive(lock))
|
|
233
|
+
return;
|
|
234
|
+
rmSync(lock, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
rmSync(reclaimLock, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Reclaim a reclaim-lock directory only when it is provably orphaned: its
|
|
244
|
+
* owner is dead (or unrecorded) AND it is older than the age bound, so a
|
|
245
|
+
* lock whose owner just mkdir'ed but has not written its owner is not stolen.
|
|
246
|
+
* Uses the same PID + start-time identity check as the main fence, so a
|
|
247
|
+
* recycled PID with a different start identity does not keep an orphaned
|
|
248
|
+
* reclaim lock alive. An ownerless (ENOENT owner) lock is still reclaimed;
|
|
249
|
+
* an unreadable owner fails closed (treated as live).
|
|
250
|
+
* Returns true when it removed the lock (caller retries), false otherwise.
|
|
251
|
+
*/
|
|
252
|
+
function reclaimOrphanedReclaimLock(reclaimLock) {
|
|
253
|
+
try {
|
|
254
|
+
if (Date.now() - statSync(reclaimLock).mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
|
|
255
|
+
return false;
|
|
256
|
+
if (lockOwnerIsAlive(reclaimLock))
|
|
257
|
+
return false;
|
|
258
|
+
rmSync(reclaimLock, { recursive: true, force: true });
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
return isEnoent(error);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function lockOwnerIsAlive(lock) {
|
|
266
|
+
let owner;
|
|
267
|
+
try {
|
|
268
|
+
owner = readFileSync(join(lock, "owner"), "utf8").trim();
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
272
|
+
return false;
|
|
273
|
+
// An unreadable owner file fails closed: treat the fence as held.
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
const separator = owner.indexOf(":");
|
|
277
|
+
const pid = Number.parseInt(separator < 0 ? owner : owner.slice(0, separator), 10);
|
|
278
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
279
|
+
return false;
|
|
280
|
+
const recordedIdentity = separator < 0 ? "" : owner.slice(separator + 1);
|
|
281
|
+
// PID + start-time identity: a recycled PID can never match the holder.
|
|
282
|
+
const currentIdentity = processStartIdentity(pid);
|
|
283
|
+
if (currentIdentity !== undefined) {
|
|
284
|
+
return recordedIdentity !== "" && currentIdentity === recordedIdentity;
|
|
285
|
+
}
|
|
286
|
+
// Off /proc (non-Linux): fall back to liveness.
|
|
287
|
+
return processIsAlive(pid);
|
|
288
|
+
}
|
|
289
|
+
/** Linux process start time (clock ticks since boot) for a PID; undefined off /proc. */
|
|
290
|
+
function processStartIdentity(pid = process.pid) {
|
|
291
|
+
try {
|
|
292
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
293
|
+
const close = stat.lastIndexOf(")");
|
|
294
|
+
const fields = stat.slice(close + 2).split(" ");
|
|
295
|
+
return fields[19] ?? "0"; // field 22: starttime
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function processIsAlive(pid) {
|
|
302
|
+
try {
|
|
303
|
+
process.kill(pid, 0);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function isEnoent(error) {
|
|
311
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
312
|
+
}
|
|
313
|
+
function isEexist(error) {
|
|
314
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
315
|
+
}
|
|
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
2
2
|
import { retireConfirmedAbsentInactiveTaskRolePlaceholders } from "../executor/agentExecutor.js";
|
|
3
3
|
import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
4
4
|
import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
|
|
5
|
+
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
5
6
|
import { WorkspaceCleanupBlockedError } from "./taskWorkspacePreparer.js";
|
|
6
7
|
export { WorkspaceCleanupBlockedError } from "./taskWorkspacePreparer.js";
|
|
7
8
|
/**
|
|
@@ -71,15 +72,32 @@ export class TaskWorkspaceCoordinator {
|
|
|
71
72
|
}
|
|
72
73
|
if (item.workspaceDisposition === disposition)
|
|
73
74
|
return "missing";
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
// Hold the per-Project maintenance fence so a concurrent migrate/rebuild/
|
|
76
|
+
// archive cannot interleave with worktree removal.
|
|
77
|
+
const workspace = this.store.getWorkItemWorkspace(item.taskId, item.id);
|
|
78
|
+
const projectIds = workspace === null
|
|
79
|
+
? []
|
|
80
|
+
: workspace.entries
|
|
81
|
+
.filter(({ access }) => access === "write")
|
|
82
|
+
.map(({ projectId }) => projectId);
|
|
83
|
+
const releaseMaintenance = projectIds.length === 0
|
|
84
|
+
? () => { }
|
|
85
|
+
: acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
86
|
+
try {
|
|
87
|
+
const state = await this.preparer.inspectWorkItemWorkspace(item.taskId, item.id);
|
|
88
|
+
if (state === "dirty")
|
|
89
|
+
return "dirty";
|
|
90
|
+
this.#assertWorkItemRuntimeQuiescent(item);
|
|
91
|
+
this.#assertNoActiveWorkItemDurableJobs(item);
|
|
92
|
+
await this.#stopLiveRoles(item.taskId, this.#workItemRoleNames(item));
|
|
93
|
+
const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForWorkItem(item.taskId, item.id);
|
|
94
|
+
if (laneCleanup === "dirty")
|
|
95
|
+
return "dirty";
|
|
96
|
+
return this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
releaseMaintenance();
|
|
100
|
+
}
|
|
83
101
|
}
|
|
84
102
|
async cleanupWorkItemRuntime(taskId, workItemId) {
|
|
85
103
|
const item = this.store.getWorkItem(taskId, workItemId);
|
|
@@ -96,16 +114,33 @@ export class TaskWorkspaceCoordinator {
|
|
|
96
114
|
if (round.status !== "completed" && round.status !== "failed") {
|
|
97
115
|
throw new Error(`ReviewRound must be terminal before cleanup: ${round.id}.`);
|
|
98
116
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
// Hold the per-Project maintenance fence so a concurrent migrate/rebuild/
|
|
118
|
+
// archive cannot interleave with worktree removal.
|
|
119
|
+
const workspace = this.store.getReviewRoundWorkspace(taskId, reviewRoundId);
|
|
120
|
+
const projectIds = workspace === null
|
|
121
|
+
? []
|
|
122
|
+
: workspace.entries
|
|
123
|
+
.filter(({ access }) => access === "write")
|
|
124
|
+
.map(({ projectId }) => projectId);
|
|
125
|
+
const releaseMaintenance = projectIds.length === 0
|
|
126
|
+
? () => { }
|
|
127
|
+
: acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
128
|
+
try {
|
|
129
|
+
const state = await this.preparer.inspectReviewRoundWorkspace(taskId, reviewRoundId);
|
|
130
|
+
if (state === "dirty")
|
|
131
|
+
return "dirty";
|
|
132
|
+
await this.#stopLiveRoles(taskId, this.#reviewRoundRoleNames(round));
|
|
133
|
+
const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId);
|
|
134
|
+
if (laneCleanup === "dirty")
|
|
135
|
+
return "dirty";
|
|
136
|
+
return this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
releaseMaintenance();
|
|
140
|
+
}
|
|
107
141
|
}
|
|
108
142
|
async cleanupTaskForArchive(taskId, disposition) {
|
|
143
|
+
let releaseMaintenance;
|
|
109
144
|
try {
|
|
110
145
|
const task = this.store.getTask(taskId);
|
|
111
146
|
if (task === null)
|
|
@@ -116,6 +151,15 @@ export class TaskWorkspaceCoordinator {
|
|
|
116
151
|
const managedWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
|
|
117
152
|
.sort((left, right) => managedWorkspaceKey(left.owner)
|
|
118
153
|
.localeCompare(managedWorkspaceKey(right.owner)));
|
|
154
|
+
// Archive cleanup removes worktrees from every Project the Task uses:
|
|
155
|
+
// hold each Project's maintenance fence so the Controller defers
|
|
156
|
+
// preparation and no migrate/rebuild/archive interleaves.
|
|
157
|
+
const projectIds = new Set(task.projectBindings.map(({ projectId }) => projectId));
|
|
158
|
+
for (const workspace of managedWorkspaces) {
|
|
159
|
+
for (const entry of workspace.entries)
|
|
160
|
+
projectIds.add(entry.projectId);
|
|
161
|
+
}
|
|
162
|
+
releaseMaintenance = acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
|
|
119
163
|
const laneWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "execution-lane");
|
|
120
164
|
const workspaces = managedWorkspaces
|
|
121
165
|
.filter(({ owner }) => owner.type === "work-item");
|
|
@@ -207,6 +251,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
207
251
|
if (roleNames.length > 0) {
|
|
208
252
|
await this.runtime.stopTaskRoleSessions(taskId, roleNames);
|
|
209
253
|
}
|
|
254
|
+
await this.runtime.assertTaskPhysicalResourcesReleased?.(task.id);
|
|
210
255
|
this.#assertTaskArchiveSnapshot(snapshot);
|
|
211
256
|
for (const workspace of laneWorkspaces) {
|
|
212
257
|
this.#assertTaskArchiveLifecycle(task);
|
|
@@ -245,6 +290,9 @@ export class TaskWorkspaceCoordinator {
|
|
|
245
290
|
})
|
|
246
291
|
};
|
|
247
292
|
}
|
|
293
|
+
finally {
|
|
294
|
+
releaseMaintenance?.();
|
|
295
|
+
}
|
|
248
296
|
}
|
|
249
297
|
#assertTaskArchiveSnapshot(snapshot) {
|
|
250
298
|
this.#assertTaskArchiveLifecycle(snapshot.task);
|
|
@@ -274,6 +322,28 @@ export class TaskWorkspaceCoordinator {
|
|
|
274
322
|
throw new WorkspaceCleanupBlockedError("active-run", `work-item:${item.taskId}/${item.id}`, true, `Work item still has an active Run: ${item.taskId}/${item.id}.`);
|
|
275
323
|
}
|
|
276
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* rr5/f4: A WorkItem workspace must not be removed while a DurableJob it
|
|
327
|
+
* owns could still be using it. Queued, running, and unacknowledged
|
|
328
|
+
* unknown-needs-attention jobs are unsettled — the runner (or its corpse)
|
|
329
|
+
* may still hold the worktree. Acknowledged unknown jobs are settled: a
|
|
330
|
+
* human/Leader has taken responsibility for the outcome.
|
|
331
|
+
*/
|
|
332
|
+
#assertNoActiveWorkItemDurableJobs(item) {
|
|
333
|
+
const jobs = this.store.listDurableJobs?.(item.taskId);
|
|
334
|
+
if (jobs === undefined)
|
|
335
|
+
return;
|
|
336
|
+
const blocking = jobs.filter((job) => (job.owner.kind === "work-item"
|
|
337
|
+
&& job.owner.workItemId === item.id
|
|
338
|
+
&& (job.status === "queued"
|
|
339
|
+
|| job.status === "running"
|
|
340
|
+
|| (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))));
|
|
341
|
+
if (blocking.length > 0) {
|
|
342
|
+
throw new WorkspaceCleanupBlockedError("active-durable-job", `work-item:${item.taskId}/${item.id}`, true, `Work item ${item.id} still has ${blocking.length} active DurableJob(s): `
|
|
343
|
+
+ `${blocking.map((job) => `${job.id}/${job.status}`).join(", ")}. `
|
|
344
|
+
+ "Cancel or acknowledge them before cleanup.");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
277
347
|
#workItemRoleNames(item) {
|
|
278
348
|
return [
|
|
279
349
|
...(item.assignee === undefined ? [] : [item.assignee]),
|