@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
|
@@ -3,14 +3,26 @@ import { isAbsolute, join, relative, resolve } from "node:path";
|
|
|
3
3
|
import { isDeepStrictEqual } from "node:util";
|
|
4
4
|
import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
|
|
5
5
|
import { updateRole } from "../role/role.js";
|
|
6
|
+
import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
6
7
|
import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
|
|
8
|
+
import { StorageConflictError } from "../storage/taskStore.js";
|
|
7
9
|
import { bindTaskWorkspaceIdentity } from "../task/task.js";
|
|
8
10
|
import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
|
|
9
11
|
import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
|
|
12
|
+
import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
|
|
10
13
|
import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
|
|
11
|
-
import {
|
|
14
|
+
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
15
|
+
import { generateTaskWorkspaceIdentity, isLegacyTaskRef, taskArchiveRef, taskMainBranch, taskWorkspaceRefSegment, taskWorkspaceRefSegmentFromIdentity, TASK_WORKSPACE_TOKEN_PATTERN, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
|
|
16
|
+
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
12
17
|
const MAIN_WORKTREE = "main";
|
|
13
18
|
const LEADER_ROLE = "leader";
|
|
19
|
+
/**
|
|
20
|
+
* Bound for prepare attempts after a lost identity race or a storage
|
|
21
|
+
* revision conflict. Each conflict already discarded the attempt's refs, so
|
|
22
|
+
* retrying converges with the committed state; the bound stops a persistent
|
|
23
|
+
* competitor from pinning this caller forever.
|
|
24
|
+
*/
|
|
25
|
+
const TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES = 3;
|
|
14
26
|
export class WorkspaceCleanupBlockedError extends Error {
|
|
15
27
|
reason;
|
|
16
28
|
resource;
|
|
@@ -46,7 +58,85 @@ export class FileTaskWorkspacePreparer {
|
|
|
46
58
|
this.git = git;
|
|
47
59
|
this.now = now;
|
|
48
60
|
}
|
|
61
|
+
#resourceRegistrarValue;
|
|
62
|
+
#resourceRegistrar() {
|
|
63
|
+
return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home, this.now);
|
|
64
|
+
}
|
|
65
|
+
#registerWorkspace(workspace) {
|
|
66
|
+
this.#resourceRegistrar().registerManagedWorkspace(workspace);
|
|
67
|
+
}
|
|
49
68
|
async prepareTaskWorkspace(taskId) {
|
|
69
|
+
// The per-Project maintenance fence makes prepare mutually exclusive with
|
|
70
|
+
// migrate/rebuild/archive: a concurrent migration must not switch the
|
|
71
|
+
// Project catalog to the Home-managed repo while prepare is creating
|
|
72
|
+
// worktrees from the old external checkout. The fence is acquired on
|
|
73
|
+
// every conflict retry so the locked set always covers the Task's
|
|
74
|
+
// current bindings. Gitless Tasks hold no fence. The Controller's
|
|
75
|
+
// check-then-call probe is kept for scheduling deferral; the fence here
|
|
76
|
+
// makes the prepare itself safe regardless.
|
|
77
|
+
for (let attempt = 0;; attempt += 1) {
|
|
78
|
+
try {
|
|
79
|
+
const task = requireTask(this.store, taskId);
|
|
80
|
+
const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
|
|
81
|
+
try {
|
|
82
|
+
return await this.#prepareTaskWorkspaceLocked(current.id);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
release();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof StorageConflictError
|
|
90
|
+
&& attempt < TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Acquire the per-Project maintenance fences for every Project bound to a
|
|
99
|
+
* Task, then re-read the Task under those fences and prove its binding set
|
|
100
|
+
* is still the one the fences cover. Gitless Tasks hold no fence.
|
|
101
|
+
*
|
|
102
|
+
* The returned Task is the fresh under-lock snapshot; callers MUST use it
|
|
103
|
+
* (not the pre-lock snapshot) for every Project read and Git effect. A
|
|
104
|
+
* binding-set change rides the StorageConflictError retry channel so the
|
|
105
|
+
* caller re-reads the Task and re-acquires the correct fence set. The
|
|
106
|
+
* caller owns `release` and MUST call it on every exit path.
|
|
107
|
+
*/
|
|
108
|
+
#acquireTaskProjectMaintenanceLocks(task) {
|
|
109
|
+
const projectIds = task.projectBindings.map(({ projectId }) => projectId);
|
|
110
|
+
const release = projectIds.length === 0
|
|
111
|
+
? () => { }
|
|
112
|
+
: acquireProjectMaintenanceLocks(this.home, projectIds);
|
|
113
|
+
try {
|
|
114
|
+
const current = requireTask(this.store, task.id);
|
|
115
|
+
const currentIds = current.projectBindings.map(({ projectId }) => projectId).sort();
|
|
116
|
+
const lockedIds = [...projectIds].sort();
|
|
117
|
+
if (currentIds.length !== lockedIds.length
|
|
118
|
+
|| currentIds.some((id, index) => id !== lockedIds[index])) {
|
|
119
|
+
throw new StorageConflictError(`Task Project bindings changed while its workspace fence was acquired: ${task.id}.`);
|
|
120
|
+
}
|
|
121
|
+
return { release, current };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
release();
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Acquire the per-Project maintenance fences for a Task's bindings and
|
|
130
|
+
* return the under-lock Task snapshot plus the release handle. Used by the
|
|
131
|
+
* dispatch preflight to hold ONE fence across new-Lane preparation and the
|
|
132
|
+
* command's adoption transaction, so a migrate cannot switch the Project
|
|
133
|
+
* catalog in that gap. The caller owns `release` and must call it on every
|
|
134
|
+
* exit path.
|
|
135
|
+
*/
|
|
136
|
+
acquireTaskProjectMaintenanceLocks(taskId) {
|
|
137
|
+
return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId));
|
|
138
|
+
}
|
|
139
|
+
async #prepareTaskWorkspaceLocked(taskId) {
|
|
50
140
|
const task = requireTask(this.store, taskId);
|
|
51
141
|
if (!["draft", "active"].includes(task.status)) {
|
|
52
142
|
throw new Error(`Task is not open for workspace preparation: ${task.id}.`);
|
|
@@ -69,6 +159,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
69
159
|
root,
|
|
70
160
|
entries: []
|
|
71
161
|
}, this.now());
|
|
162
|
+
this.#registerWorkspace(workspace);
|
|
72
163
|
this.store.transaction((tx) => {
|
|
73
164
|
const latest = requireTask(tx, task.id);
|
|
74
165
|
if (!['draft', 'active'].includes(latest.status)
|
|
@@ -198,6 +289,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
198
289
|
root,
|
|
199
290
|
entries: prepared.map(({ entry }) => entry)
|
|
200
291
|
}, this.now());
|
|
292
|
+
this.#registerWorkspace(workspace);
|
|
201
293
|
this.store.transaction((tx) => {
|
|
202
294
|
const latest = requireTask(tx, task.id);
|
|
203
295
|
if (!["draft", "active"].includes(latest.status)) {
|
|
@@ -206,10 +298,36 @@ export class FileTaskWorkspacePreparer {
|
|
|
206
298
|
if (!isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
|
|
207
299
|
throw new Error(`Task Projects changed while preparing its workspace: ${task.id}.`);
|
|
208
300
|
}
|
|
301
|
+
// Revalidate the Project catalog path after acquiring the fence: a
|
|
302
|
+
// concurrent migration must not leave a persisted worktree whose Git
|
|
303
|
+
// common dir is the old external checkout. A changed path rides the
|
|
304
|
+
// StorageConflictError retry channel so the retry re-reads the catalog
|
|
305
|
+
// and prepares against the current (Home-managed) repository.
|
|
306
|
+
for (const { project } of prepared) {
|
|
307
|
+
const latestProject = requireProject(tx, project.id);
|
|
308
|
+
if (latestProject.path !== project.path) {
|
|
309
|
+
throw new StorageConflictError(`Project path changed while preparing its workspace: ${project.id}.`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
209
312
|
const current = tx.getTaskWorkspace(task.id);
|
|
210
313
|
if (current !== null && current.owner.type !== "task") {
|
|
211
314
|
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
212
315
|
}
|
|
316
|
+
// Single-writer CAS, mirroring the rebuild guard: this attempt only
|
|
317
|
+
// commits while the Task is still unbound (or carries the identity it
|
|
318
|
+
// started with). A concurrent prepare or rebuild that bound a
|
|
319
|
+
// different identity wins; this attempt discards its refs and retries
|
|
320
|
+
// through the StorageConflictError channel.
|
|
321
|
+
if (workspaceIdentity !== undefined
|
|
322
|
+
&& latest.workspaceIdentity !== undefined
|
|
323
|
+
&& !isDeepStrictEqual(validateTaskWorkspaceIdentity(latest.workspaceIdentity), workspaceIdentity)) {
|
|
324
|
+
throw new StorageConflictError(`Task workspace identity changed while preparing its workspace: ${task.id}.`);
|
|
325
|
+
}
|
|
326
|
+
if (current === null
|
|
327
|
+
? existing !== null
|
|
328
|
+
: existing === null || !sameManagedWorkspace(current, existing)) {
|
|
329
|
+
throw new StorageConflictError(`Task workspace changed while preparing its workspace: ${task.id}.`);
|
|
330
|
+
}
|
|
213
331
|
const pinnedBindings = latest.projectBindings.map((binding) => {
|
|
214
332
|
const baseline = baselines.get(binding.projectId);
|
|
215
333
|
return baseline?.pinTask === true
|
|
@@ -237,15 +355,31 @@ export class FileTaskWorkspacePreparer {
|
|
|
237
355
|
// The Role field is only a cwd/snapshot hint. Preserve the hint for
|
|
238
356
|
// an active WorkItem assignment; the durable owner is the WorkItem,
|
|
239
357
|
// not this Role record. Other Roles use Task main.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
358
|
+
// Prefer the active Run's exact WorkItem for this Role; fall back
|
|
359
|
+
// to the first queued WorkItem only when no active Run owns the Role.
|
|
360
|
+
const activeRoleRun = tx.getActiveAgentRun(task.id, role.name);
|
|
361
|
+
const activeRunItem = activeRoleRun !== null
|
|
362
|
+
&& activeRoleRun.purpose === "execution"
|
|
363
|
+
&& activeRoleRun.workItemId !== undefined
|
|
364
|
+
? tx.getWorkItem(task.id, activeRoleRun.workItemId)
|
|
365
|
+
: null;
|
|
366
|
+
const assignedItem = activeRunItem !== null
|
|
367
|
+
&& activeRunItem.assignee === role.name
|
|
368
|
+
&& !["completed", "failed", "retired"].includes(activeRunItem.status)
|
|
369
|
+
? activeRunItem
|
|
370
|
+
: tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
|
|
371
|
+
&& !["completed", "failed", "retired"]
|
|
372
|
+
.includes(candidate.status)));
|
|
243
373
|
const assignedWorkspace = assignedItem === undefined
|
|
244
374
|
? null
|
|
245
375
|
: tx.getWorkItemWorkspace(task.id, assignedItem.id);
|
|
246
376
|
const target = assignedWorkspace?.root ?? root;
|
|
247
377
|
if (role.workspace !== target) {
|
|
248
|
-
|
|
378
|
+
if (assignedItem === undefined
|
|
379
|
+
|| assignedWorkspace === null
|
|
380
|
+
|| !canCorrectActiveWorkItemRoleWorkspaceHint(tx, task.id, role, assignedItem, assignedWorkspace)) {
|
|
381
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
382
|
+
}
|
|
249
383
|
tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
|
|
250
384
|
}
|
|
251
385
|
}
|
|
@@ -253,10 +387,40 @@ export class FileTaskWorkspacePreparer {
|
|
|
253
387
|
return { taskId, status: "ready", path: root };
|
|
254
388
|
}
|
|
255
389
|
catch (error) {
|
|
256
|
-
|
|
390
|
+
// A failed or conflicted preparation owns no durable record: drop the
|
|
391
|
+
// branches too, so a retry mints a clean identity without half-created
|
|
392
|
+
// refs behind. Adopted (already catalogued) worktrees are never touched.
|
|
393
|
+
await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE, true);
|
|
257
394
|
throw error;
|
|
258
395
|
}
|
|
259
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* The conflict-retry loop for a Task-main prepare, run with the caller's
|
|
399
|
+
* fences already held. A lost identity/workspace CAS retries safely under
|
|
400
|
+
* the same fence set; a binding-set change makes that fence set stale, so
|
|
401
|
+
* the conflict is rethrown and only the caller (which can re-acquire) may
|
|
402
|
+
* retry it.
|
|
403
|
+
*/
|
|
404
|
+
async #prepareTaskWorkspaceWithRetries(taskId, lockedProjectIds) {
|
|
405
|
+
for (let attempt = 0;; attempt += 1) {
|
|
406
|
+
try {
|
|
407
|
+
return await this.#prepareTaskWorkspaceLocked(taskId);
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (!(error instanceof StorageConflictError)
|
|
411
|
+
|| attempt >= TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
const current = requireTask(this.store, taskId);
|
|
415
|
+
const currentIds = current.projectBindings.map(({ projectId }) => projectId).sort();
|
|
416
|
+
const locked = [...lockedProjectIds].sort();
|
|
417
|
+
if (currentIds.length !== locked.length
|
|
418
|
+
|| currentIds.some((id, index) => id !== locked[index])) {
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
260
424
|
/**
|
|
261
425
|
* Mint the Task's durable workspace identity with create-not-exists
|
|
262
426
|
* semantics: the candidate main branch must not already exist in any bound
|
|
@@ -376,148 +540,160 @@ export class FileTaskWorkspacePreparer {
|
|
|
376
540
|
const item = requireWorkItem(this.store, taskId, workItemId);
|
|
377
541
|
const task = requireTask(this.store, item.taskId);
|
|
378
542
|
assertWorkItemWorkspaceEligible(this.store, task, item);
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
if (main === null || main.owner.type !== "task") {
|
|
385
|
-
throw new Error(`Task main workspace is not ready: ${task.id}.`);
|
|
386
|
-
}
|
|
387
|
-
const existing = this.store.getWorkItemWorkspace(task.id, item.id);
|
|
388
|
-
if (existing !== null && (existing.owner.type !== "work-item"
|
|
389
|
-
|| existing.owner.workItemId !== item.id)) {
|
|
390
|
-
throw new Error(`WorkItem workspace owner is invalid: ${task.id}/${item.id}.`);
|
|
391
|
-
}
|
|
392
|
-
if (item.assignee !== undefined) {
|
|
393
|
-
assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
|
|
394
|
-
}
|
|
395
|
-
const writeProjects = new Set(item.writeProjectIds);
|
|
396
|
-
const boundProjects = new Set(task.projectBindings.map(({ projectId }) => projectId));
|
|
397
|
-
for (const baseRef of item.baseRefs ?? []) {
|
|
398
|
-
if (!boundProjects.has(baseRef.projectId)) {
|
|
399
|
-
throw new Error(`WorkItem base-ref Project is not bound to its Task: ${item.id}/${baseRef.projectId}.`);
|
|
400
|
-
}
|
|
401
|
-
if (!writeProjects.has(baseRef.projectId)) {
|
|
402
|
-
throw new Error(`WorkItem base-ref Project is not writable: ${item.id}/${baseRef.projectId}.`);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
const root = this.#workItemWorkspaceRoot(task.id, item.id);
|
|
406
|
-
const prepared = [];
|
|
543
|
+
// Hold the per-Project maintenance fence across the ensure-main-prepared
|
|
544
|
+
// and the WorkItem worktree creation, so a concurrent migrate/rebuild
|
|
545
|
+
// cannot switch the Project catalog between the two. The under-lock Task
|
|
546
|
+
// snapshot drives every Project read and Git effect below.
|
|
547
|
+
const { release, current: lockedTask } = this.#acquireTaskProjectMaintenanceLocks(task);
|
|
407
548
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
baseRef
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
549
|
+
const lockedProjectIds = lockedTask.projectBindings.map(({ projectId }) => projectId);
|
|
550
|
+
await this.#prepareTaskWorkspaceWithRetries(lockedTask.id, lockedProjectIds);
|
|
551
|
+
// prepareTaskWorkspace may have just minted and persisted the workspace
|
|
552
|
+
// identity; derive the segment from the persisted Task.
|
|
553
|
+
const taskSegment = this.#taskSegment(requireTask(this.store, lockedTask.id));
|
|
554
|
+
const main = this.store.getTaskWorkspace(lockedTask.id);
|
|
555
|
+
if (main === null || main.owner.type !== "task") {
|
|
556
|
+
throw new Error(`Task main workspace is not ready: ${lockedTask.id}.`);
|
|
557
|
+
}
|
|
558
|
+
const existing = this.store.getWorkItemWorkspace(lockedTask.id, item.id);
|
|
559
|
+
if (existing !== null && (existing.owner.type !== "work-item"
|
|
560
|
+
|| existing.owner.workItemId !== item.id)) {
|
|
561
|
+
throw new Error(`WorkItem workspace owner is invalid: ${lockedTask.id}/${item.id}.`);
|
|
562
|
+
}
|
|
563
|
+
if (item.assignee !== undefined) {
|
|
564
|
+
assertWorkspaceSessionsRetirable(this.store, lockedTask.id, item.assignee, this.now());
|
|
565
|
+
}
|
|
566
|
+
const writeProjects = new Set(item.writeProjectIds);
|
|
567
|
+
const boundProjects = new Set(lockedTask.projectBindings.map(({ projectId }) => projectId));
|
|
568
|
+
for (const baseRef of item.baseRefs ?? []) {
|
|
569
|
+
if (!boundProjects.has(baseRef.projectId)) {
|
|
570
|
+
throw new Error(`WorkItem base-ref Project is not bound to its Task: ${item.id}/${baseRef.projectId}.`);
|
|
571
|
+
}
|
|
572
|
+
if (!writeProjects.has(baseRef.projectId)) {
|
|
573
|
+
throw new Error(`WorkItem base-ref Project is not writable: ${item.id}/${baseRef.projectId}.`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const root = this.#workItemWorkspaceRoot(lockedTask.id, item.id);
|
|
577
|
+
const prepared = [];
|
|
578
|
+
try {
|
|
579
|
+
for (const binding of lockedTask.projectBindings) {
|
|
580
|
+
const project = requireProject(this.store, binding.projectId);
|
|
581
|
+
const mainEntry = requireWorkspaceEntry(main, project.id);
|
|
582
|
+
if (!writeProjects.has(project.id)) {
|
|
583
|
+
prepared.push({
|
|
584
|
+
project,
|
|
585
|
+
entry: { ...mainEntry, access: "read" }
|
|
586
|
+
});
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
|
|
590
|
+
const head = await this.git.inspect(mainEntry.path, "HEAD");
|
|
591
|
+
const requestedBaseRef = item.baseRefs?.find(({ projectId }) => (projectId === project.id))?.baseRef;
|
|
592
|
+
const baseRef = previous?.access === "write"
|
|
446
593
|
? previous.baseCommit
|
|
447
|
-
:
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
594
|
+
: requestedBaseRef ?? head.baseCommit;
|
|
595
|
+
const requestedBase = previous?.access === "write" || requestedBaseRef === undefined
|
|
596
|
+
? null
|
|
597
|
+
: await this.git.inspect(project.path, requestedBaseRef);
|
|
598
|
+
const physical = await this.git.ensureWorktree({
|
|
599
|
+
repositoryPath: project.path,
|
|
600
|
+
container: this.#projectContainer(project.name),
|
|
601
|
+
taskSegment,
|
|
602
|
+
roleName: item.id,
|
|
603
|
+
baseRef
|
|
604
|
+
});
|
|
605
|
+
const entry = {
|
|
606
|
+
projectId: project.id,
|
|
607
|
+
directory: binding.directory,
|
|
608
|
+
access: "write",
|
|
609
|
+
path: physical.path,
|
|
610
|
+
branch: physical.branch,
|
|
611
|
+
baseRef: previous?.access === "write" ? previous.baseRef : baseRef,
|
|
612
|
+
// The recorded base is the immutable capture boundary. An existing
|
|
613
|
+
// worktree reports its current HEAD from ensureWorktree(), which
|
|
614
|
+
// may already contain committed Worker changes and must never
|
|
615
|
+
// replace that boundary during scope expansion or reconciliation.
|
|
616
|
+
baseCommit: previous?.access === "write"
|
|
617
|
+
? previous.baseCommit
|
|
618
|
+
: physical.baseCommit
|
|
619
|
+
};
|
|
620
|
+
// Track the physical entry before any postcondition check. If a
|
|
621
|
+
// freshly attached deterministic branch is rejected below, the
|
|
622
|
+
// catch-path must remove its unadopted worktree even though no
|
|
623
|
+
// durable ManagedWorkspace has been written yet.
|
|
624
|
+
prepared.push({ project, entry });
|
|
625
|
+
if (previous?.access === "write" && (physical.path !== previous.path
|
|
626
|
+
|| physical.branch !== previous.branch)) {
|
|
627
|
+
throw new Error(`Existing WorkItem Project workspace identity changed: ${item.id}/${project.id}.`);
|
|
461
628
|
}
|
|
462
|
-
if (
|
|
463
|
-
|
|
629
|
+
if (previous?.access === "write") {
|
|
630
|
+
if (requestedBaseRef === undefined && previous.baseRef !== previous.baseCommit) {
|
|
631
|
+
throw new Error(`Existing WorkItem Project base ref record changed: ${item.id}/${project.id}.`);
|
|
632
|
+
}
|
|
633
|
+
if (requestedBaseRef !== undefined && requestedBaseRef !== previous.baseRef) {
|
|
634
|
+
throw new Error(`Existing WorkItem Project base ref changed: ${item.id}/${project.id}.`);
|
|
635
|
+
}
|
|
636
|
+
if (!await this.git.isAncestor(project.path, previous.baseCommit, physical.baseCommit)) {
|
|
637
|
+
throw new Error(`Existing WorkItem Project HEAD does not descend from its frozen base: `
|
|
638
|
+
+ `${item.id}/${project.id}.`);
|
|
639
|
+
}
|
|
464
640
|
}
|
|
465
|
-
if (
|
|
466
|
-
throw new Error(`
|
|
641
|
+
else if (requestedBase !== null && physical.baseCommit !== requestedBase.baseCommit) {
|
|
642
|
+
throw new Error(`WorkItem Project workspace did not start at its requested base ref: `
|
|
467
643
|
+ `${item.id}/${project.id}.`);
|
|
468
644
|
}
|
|
469
645
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const latestTask = requireTask(tx, task.id);
|
|
483
|
-
const latestItem = tx.getWorkItem(task.id, item.id);
|
|
484
|
-
if (latestTask.status !== "active" || latestItem === null) {
|
|
485
|
-
throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
|
|
486
|
-
}
|
|
487
|
-
if (latestItem.revision !== item.revision
|
|
488
|
-
|| !isDeepStrictEqual(latestItem.writeProjectIds, item.writeProjectIds)) {
|
|
489
|
-
throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
|
|
490
|
-
}
|
|
491
|
-
const activeDevelopRun = tx.listAgentRuns(task.id)
|
|
492
|
-
.find((run) => run.status === "active" && run.workItemId === item.id);
|
|
493
|
-
if (activeDevelopRun !== undefined) {
|
|
494
|
-
throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
|
|
495
|
-
}
|
|
496
|
-
if (latestItem.assignee !== undefined
|
|
497
|
-
&& tx.getActiveAgentRun(task.id, latestItem.assignee) !== null) {
|
|
498
|
-
throw new Error(`Role has an active Run: ${task.id}/${latestItem.assignee}.`);
|
|
499
|
-
}
|
|
500
|
-
const current = tx.getWorkItemWorkspace(task.id, item.id);
|
|
501
|
-
if (current !== null && (current.owner.type !== "work-item"
|
|
502
|
-
|| current.owner.workItemId !== item.id)) {
|
|
503
|
-
throw new Error(`WorkItem workspace changed: ${task.id}/${item.id}.`);
|
|
504
|
-
}
|
|
505
|
-
const timestamp = this.now();
|
|
506
|
-
const stored = preserveWorkspaceCreatedAt(workspace, current);
|
|
507
|
-
tx.saveManagedWorkspace(stored);
|
|
508
|
-
if (latestItem.assignee !== undefined) {
|
|
509
|
-
const latestRole = tx.getRole(task.id, latestItem.assignee);
|
|
510
|
-
if (latestRole !== null && latestRole.workspace !== root) {
|
|
511
|
-
retireWorkspaceBoundSession(tx, task.id, latestItem.assignee, timestamp);
|
|
512
|
-
tx.saveRole(task.id, updateRole(latestRole, { workspace: root }, timestamp));
|
|
646
|
+
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
647
|
+
const workspace = createManagedWorkspace({
|
|
648
|
+
owner: { type: "work-item", taskId: lockedTask.id, workItemId: item.id },
|
|
649
|
+
root,
|
|
650
|
+
entries: prepared.map(({ entry }) => entry)
|
|
651
|
+
}, this.now());
|
|
652
|
+
this.#registerWorkspace(workspace);
|
|
653
|
+
return this.store.transaction((tx) => {
|
|
654
|
+
const latestTask = requireTask(tx, lockedTask.id);
|
|
655
|
+
const latestItem = tx.getWorkItem(lockedTask.id, item.id);
|
|
656
|
+
if (latestTask.status !== "active" || latestItem === null) {
|
|
657
|
+
throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
|
|
513
658
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
659
|
+
if (latestItem.revision !== item.revision
|
|
660
|
+
|| !isDeepStrictEqual(latestItem.writeProjectIds, item.writeProjectIds)) {
|
|
661
|
+
throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
|
|
662
|
+
}
|
|
663
|
+
const activeDevelopRun = tx.listAgentRuns(lockedTask.id)
|
|
664
|
+
.find((run) => run.status === "active" && run.workItemId === item.id);
|
|
665
|
+
if (activeDevelopRun !== undefined) {
|
|
666
|
+
throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
|
|
667
|
+
}
|
|
668
|
+
if (latestItem.assignee !== undefined
|
|
669
|
+
&& tx.getActiveAgentRun(lockedTask.id, latestItem.assignee) !== null) {
|
|
670
|
+
throw new Error(`Role has an active Run: ${lockedTask.id}/${latestItem.assignee}.`);
|
|
671
|
+
}
|
|
672
|
+
const existingWorkspace = tx.getWorkItemWorkspace(lockedTask.id, item.id);
|
|
673
|
+
if (existingWorkspace !== null && (existingWorkspace.owner.type !== "work-item"
|
|
674
|
+
|| existingWorkspace.owner.workItemId !== item.id)) {
|
|
675
|
+
throw new Error(`WorkItem workspace changed: ${lockedTask.id}/${item.id}.`);
|
|
676
|
+
}
|
|
677
|
+
const timestamp = this.now();
|
|
678
|
+
const stored = preserveWorkspaceCreatedAt(workspace, existingWorkspace);
|
|
679
|
+
tx.saveManagedWorkspace(stored);
|
|
680
|
+
if (latestItem.assignee !== undefined) {
|
|
681
|
+
const latestRole = tx.getRole(lockedTask.id, latestItem.assignee);
|
|
682
|
+
if (latestRole !== null && latestRole.workspace !== root) {
|
|
683
|
+
retireWorkspaceBoundSession(tx, lockedTask.id, latestItem.assignee, timestamp);
|
|
684
|
+
tx.saveRole(lockedTask.id, updateRole(latestRole, { workspace: root }, timestamp));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return stored;
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, item.id);
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
517
694
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
throw error;
|
|
695
|
+
finally {
|
|
696
|
+
release();
|
|
521
697
|
}
|
|
522
698
|
}
|
|
523
699
|
/**
|
|
@@ -525,112 +701,128 @@ export class FileTaskWorkspacePreparer {
|
|
|
525
701
|
* Git identity, ownership, and cleanup are delegated to this preparer; the
|
|
526
702
|
* command layer only consumes the resulting managed record.
|
|
527
703
|
*/
|
|
528
|
-
async prepareExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId, hint) {
|
|
704
|
+
async prepareExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId, hint, heldFence) {
|
|
529
705
|
const task = requireTask(this.store, taskId);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
? {
|
|
540
|
-
type: "execution-lane",
|
|
541
|
-
taskId,
|
|
542
|
-
executionGroupId,
|
|
543
|
-
executionLaneId,
|
|
544
|
-
purpose: "execution",
|
|
545
|
-
workItemId: lineage.workItemId
|
|
546
|
-
}
|
|
547
|
-
: {
|
|
548
|
-
type: "execution-lane",
|
|
549
|
-
taskId,
|
|
550
|
-
executionGroupId,
|
|
551
|
-
executionLaneId,
|
|
552
|
-
purpose: "review",
|
|
553
|
-
reviewRoundId: lineage.reviewRoundId
|
|
554
|
-
};
|
|
555
|
-
const existing = this.store.getManagedWorkspace(owner);
|
|
556
|
-
if (existing !== null) {
|
|
557
|
-
if (existing.owner.type !== "execution-lane" || existing.root !== this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId)) {
|
|
558
|
-
throw new Error(`Execution Lane managed workspace identity changed: ${taskId}/${executionLaneId}.`);
|
|
559
|
-
}
|
|
560
|
-
await ensureWorkspaceView(existing.root, existing.entries);
|
|
561
|
-
for (const entry of existing.entries.filter(({ access }) => access === "write")) {
|
|
562
|
-
const project = requireProject(this.store, entry.projectId);
|
|
563
|
-
const physical = await this.git.ensureWorktree({
|
|
564
|
-
repositoryPath: project.path,
|
|
565
|
-
container: this.#projectContainer(project.name),
|
|
566
|
-
taskSegment,
|
|
567
|
-
roleName: managedWorktreeName(owner),
|
|
568
|
-
baseRef: entry.baseCommit
|
|
569
|
-
});
|
|
570
|
-
if (physical.path !== entry.path || physical.branch !== entry.branch) {
|
|
571
|
-
throw new Error(`Execution Lane physical identity changed: ${taskId}/${executionLaneId}/${entry.projectId}.`);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
return existing;
|
|
575
|
-
}
|
|
576
|
-
const item = lineage.purpose === "execution"
|
|
577
|
-
? this.store.getWorkItem(taskId, lineage.workItemId)
|
|
578
|
-
: null;
|
|
579
|
-
const writable = new Set(lineage.purpose === "execution"
|
|
580
|
-
? item?.writeProjectIds ?? []
|
|
581
|
-
: task.projectBindings.map(({ projectId }) => projectId));
|
|
582
|
-
const prepared = [];
|
|
706
|
+
// Hold the per-Project maintenance fence across Lane worktree creation
|
|
707
|
+
// (both the reuse and the fresh-mint paths), so a concurrent
|
|
708
|
+
// migrate/rebuild cannot switch the Project catalog mid-prepare. A
|
|
709
|
+
// dispatch preflight that prepares a whole new Group passes the fence it
|
|
710
|
+
// already holds (one locked boundary across preparation and adoption);
|
|
711
|
+
// otherwise the fence is acquired here.
|
|
712
|
+
const { release, current: lockedTask } = heldFence === undefined
|
|
713
|
+
? this.#acquireTaskProjectMaintenanceLocks(task)
|
|
714
|
+
: { release: () => { }, current: heldFence.current };
|
|
583
715
|
try {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
716
|
+
const lineage = executionLaneLineage(this.store, lockedTask, executionGroupId, executionLaneId, hint);
|
|
717
|
+
const source = lineage.purpose === "execution"
|
|
718
|
+
? this.store.getWorkItemWorkspace(taskId, lineage.workItemId)
|
|
719
|
+
: this.store.getReviewRoundWorkspace(taskId, lineage.reviewRoundId);
|
|
720
|
+
if (source === null) {
|
|
721
|
+
throw new Error(`Execution Lane source workspace is not ready: ${executionLaneId}.`);
|
|
722
|
+
}
|
|
723
|
+
const taskSegment = this.#taskSegment(lockedTask);
|
|
724
|
+
const owner = lineage.purpose === "execution"
|
|
725
|
+
? {
|
|
726
|
+
type: "execution-lane",
|
|
727
|
+
taskId,
|
|
728
|
+
executionGroupId,
|
|
729
|
+
executionLaneId,
|
|
730
|
+
purpose: "execution",
|
|
731
|
+
workItemId: lineage.workItemId
|
|
590
732
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
733
|
+
: {
|
|
734
|
+
type: "execution-lane",
|
|
735
|
+
taskId,
|
|
736
|
+
executionGroupId,
|
|
737
|
+
executionLaneId,
|
|
738
|
+
purpose: "review",
|
|
739
|
+
reviewRoundId: lineage.reviewRoundId
|
|
740
|
+
};
|
|
741
|
+
const existing = this.store.getManagedWorkspace(owner);
|
|
742
|
+
if (existing !== null) {
|
|
743
|
+
if (existing.owner.type !== "execution-lane" || existing.root !== this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId)) {
|
|
744
|
+
throw new Error(`Execution Lane managed workspace identity changed: ${taskId}/${executionLaneId}.`);
|
|
745
|
+
}
|
|
746
|
+
await ensureWorkspaceView(existing.root, existing.entries);
|
|
747
|
+
this.#registerWorkspace(existing);
|
|
748
|
+
for (const entry of existing.entries.filter(({ access }) => access === "write")) {
|
|
749
|
+
const project = requireProject(this.store, entry.projectId);
|
|
750
|
+
const physical = await this.git.ensureWorktree({
|
|
751
|
+
repositoryPath: project.path,
|
|
752
|
+
container: this.#projectContainer(project.name),
|
|
753
|
+
taskSegment,
|
|
754
|
+
roleName: managedWorktreeName(owner),
|
|
755
|
+
baseRef: entry.baseCommit
|
|
756
|
+
});
|
|
757
|
+
if (physical.path !== entry.path || physical.branch !== entry.branch) {
|
|
758
|
+
throw new Error(`Execution Lane physical identity changed: ${taskId}/${executionLaneId}/${entry.projectId}.`);
|
|
607
759
|
}
|
|
608
|
-
}
|
|
760
|
+
}
|
|
761
|
+
return existing;
|
|
609
762
|
}
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
763
|
+
const item = lineage.purpose === "execution"
|
|
764
|
+
? this.store.getWorkItem(taskId, lineage.workItemId)
|
|
765
|
+
: null;
|
|
766
|
+
const writable = new Set(lineage.purpose === "execution"
|
|
767
|
+
? item?.writeProjectIds ?? []
|
|
768
|
+
: lockedTask.projectBindings.map(({ projectId }) => projectId));
|
|
769
|
+
const prepared = [];
|
|
770
|
+
try {
|
|
771
|
+
for (const binding of lockedTask.projectBindings) {
|
|
772
|
+
const project = requireProject(this.store, binding.projectId);
|
|
773
|
+
const sourceEntry = requireWorkspaceEntry(source, project.id);
|
|
774
|
+
if (!writable.has(project.id)) {
|
|
775
|
+
prepared.push({ project, entry: { ...sourceEntry, access: "read" } });
|
|
776
|
+
continue;
|
|
624
777
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
778
|
+
const physical = await this.git.ensureWorktree({
|
|
779
|
+
repositoryPath: project.path,
|
|
780
|
+
container: this.#projectContainer(project.name),
|
|
781
|
+
taskSegment,
|
|
782
|
+
roleName: managedWorktreeName(owner),
|
|
783
|
+
baseRef: sourceEntry.baseCommit
|
|
784
|
+
});
|
|
785
|
+
prepared.push({
|
|
786
|
+
project,
|
|
787
|
+
entry: {
|
|
788
|
+
...sourceEntry,
|
|
789
|
+
access: "write",
|
|
790
|
+
path: physical.path,
|
|
791
|
+
branch: physical.branch,
|
|
792
|
+
baseRef: sourceEntry.baseCommit,
|
|
793
|
+
baseCommit: sourceEntry.baseCommit
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
|
|
798
|
+
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
799
|
+
const workspace = createManagedWorkspace({
|
|
800
|
+
owner,
|
|
801
|
+
root,
|
|
802
|
+
entries: prepared.map(({ entry }) => entry)
|
|
803
|
+
}, this.now());
|
|
804
|
+
this.#registerWorkspace(workspace);
|
|
805
|
+
const durableLane = this.store.listWorkItems(taskId).some((item) => (workItemExecutionGroupById(item, executionGroupId)?.lanes.some(({ id }) => id === executionLaneId))) || this.store.listReviewRounds(taskId).some((round) => (round.executionGroup?.id === executionGroupId
|
|
806
|
+
&& round.executionGroup.lanes.some(({ id }) => id === executionLaneId)));
|
|
807
|
+
if (durableLane) {
|
|
808
|
+
return this.store.transaction((tx) => {
|
|
809
|
+
const current = tx.getManagedWorkspace(owner);
|
|
810
|
+
if (current !== null && !isDeepStrictEqual(current, workspace)) {
|
|
811
|
+
throw new Error(`Execution Lane workspace changed before adoption: ${executionLaneId}.`);
|
|
812
|
+
}
|
|
813
|
+
tx.saveManagedWorkspace(current ?? workspace);
|
|
814
|
+
return current ?? workspace;
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return workspace;
|
|
818
|
+
}
|
|
819
|
+
catch (error) {
|
|
820
|
+
await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, managedWorktreeName(owner), true);
|
|
821
|
+
throw error;
|
|
628
822
|
}
|
|
629
|
-
return workspace;
|
|
630
823
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
throw error;
|
|
824
|
+
finally {
|
|
825
|
+
release();
|
|
634
826
|
}
|
|
635
827
|
}
|
|
636
828
|
/**
|
|
@@ -848,6 +1040,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
848
1040
|
removed ||= result === "removed";
|
|
849
1041
|
}
|
|
850
1042
|
await removeWorkspaceView(workspace.root);
|
|
1043
|
+
this.#resourceRegistrar().markWorkspaceDeleted(workspace);
|
|
851
1044
|
this.store.removeManagedWorkspace(workspace.owner);
|
|
852
1045
|
return removed ? "removed" : "missing";
|
|
853
1046
|
}
|
|
@@ -860,11 +1053,33 @@ export class FileTaskWorkspacePreparer {
|
|
|
860
1053
|
return "missing";
|
|
861
1054
|
const task = requireTask(this.store, workspace.owner.taskId);
|
|
862
1055
|
const taskSegment = this.#taskSegment(task);
|
|
863
|
-
const
|
|
1056
|
+
const writable = workspace.entries.filter(({ access }) => access === "write");
|
|
1057
|
+
let state;
|
|
1058
|
+
try {
|
|
1059
|
+
state = await this.#inspectEntries(taskSegment, managedWorktreeName(workspace.owner), writable);
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
// A `project migrate` between preparation and compensation switches the
|
|
1063
|
+
// catalog, so the worktree's common-dir no longer matches the Project's
|
|
1064
|
+
// current repository. Fall back to removing the stranded worktree
|
|
1065
|
+
// through its own Git identity.
|
|
1066
|
+
if (!(error instanceof Error && error.message.includes("belongs to another project"))) {
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
let removed = false;
|
|
1070
|
+
for (const entry of writable) {
|
|
1071
|
+
const result = await this.git.removeStrandedWorktree(entry.path);
|
|
1072
|
+
if (result === "dirty")
|
|
1073
|
+
return "dirty";
|
|
1074
|
+
removed ||= result === "removed";
|
|
1075
|
+
}
|
|
1076
|
+
await removeWorkspaceView(workspace.root);
|
|
1077
|
+
return removed ? "removed" : "missing";
|
|
1078
|
+
}
|
|
864
1079
|
if (state === "dirty")
|
|
865
1080
|
return "dirty";
|
|
866
1081
|
let removed = false;
|
|
867
|
-
for (const entry of
|
|
1082
|
+
for (const entry of writable) {
|
|
868
1083
|
const project = requireProject(this.store, entry.projectId);
|
|
869
1084
|
const result = await this.git.removeWorktree({
|
|
870
1085
|
repositoryPath: project.path,
|
|
@@ -929,287 +1144,295 @@ export class FileTaskWorkspacePreparer {
|
|
|
929
1144
|
* frozen Develop snapshot. This record is owned by the ReviewRound; it is
|
|
930
1145
|
* never used as a ChangeSet capture source. */
|
|
931
1146
|
async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
932
|
-
const
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}.`);
|
|
1007
|
-
}
|
|
1008
|
-
for (const entry of existing.entries) {
|
|
1009
|
-
const source = expectedEntries.get(entry.projectId);
|
|
1010
|
-
if (source === undefined) {
|
|
1011
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}/${entry.projectId}.`);
|
|
1012
|
-
}
|
|
1013
|
-
if (!sameCommit(entry.baseCommit, source.baseCommit)) {
|
|
1014
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace baseCommit record mismatch for ${round.id}/${entry.projectId}: `
|
|
1015
|
-
+ `expected ${source.baseCommit}, recorded ${entry.baseCommit}.`);
|
|
1016
|
-
}
|
|
1017
|
-
if (entry.directory !== source.directory
|
|
1018
|
-
|| entry.access !== "write"
|
|
1019
|
-
|| !sameCommit(entry.baseRef, source.baseCommit)) {
|
|
1020
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
|
|
1021
|
-
}
|
|
1022
|
-
const project = requireProject(this.store, entry.projectId);
|
|
1023
|
-
const identity = worktreeIdentity(taskSegment, round.id);
|
|
1024
|
-
const expectedPath = join(this.#projectContainer(project.name), identity.directory);
|
|
1025
|
-
if (entry.path !== expectedPath || entry.branch !== identity.branch) {
|
|
1026
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
|
|
1147
|
+
const taskSnapshot = requireTask(this.store, taskId);
|
|
1148
|
+
const { release, current: task } = this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
|
|
1149
|
+
try {
|
|
1150
|
+
const taskSegment = this.#taskSegment(task);
|
|
1151
|
+
const round = this.store.getReviewRound(task.id, reviewRoundId);
|
|
1152
|
+
if (round === null)
|
|
1153
|
+
throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
|
|
1154
|
+
if (round.status !== "pending") {
|
|
1155
|
+
throw new Error(`ReviewRound workspace can only prepare while pending: ${round.id}.`);
|
|
1156
|
+
}
|
|
1157
|
+
const item = requireWorkItem(this.store, task.id, round.workItemId);
|
|
1158
|
+
const candidate = item.candidates.find(({ id }) => id === round.candidateId);
|
|
1159
|
+
if (candidate === undefined) {
|
|
1160
|
+
throw new Error(`ReviewRound Candidate not found: ${round.candidateId}.`);
|
|
1161
|
+
}
|
|
1162
|
+
const taskScope = (round.scope ?? "work-item") === "task";
|
|
1163
|
+
// A WorkItem ReviewRound is an immutable snapshot of Develop. A Task
|
|
1164
|
+
// ReviewRound intentionally uses the latest committed Integration heads
|
|
1165
|
+
// instead, while retaining the WorkItem/Candidate anchor for storage and
|
|
1166
|
+
// lifecycle compatibility.
|
|
1167
|
+
const develop = candidate.workspace;
|
|
1168
|
+
if (!taskScope && (develop === undefined || candidate.gitSnapshot === undefined)) {
|
|
1169
|
+
throw new Error(`Candidate has no frozen managed Git snapshot: ${candidate.id}.`);
|
|
1170
|
+
}
|
|
1171
|
+
if (!taskScope && candidate.gitSnapshot.reviewBaseCommit !== round.reviewBaseCommit) {
|
|
1172
|
+
throw new Error(`ReviewRound base no longer matches its Candidate: ${round.id}.`);
|
|
1173
|
+
}
|
|
1174
|
+
const reviewer = this.store.getRole(task.id, round.reviewerRoleName);
|
|
1175
|
+
if (reviewer === null) {
|
|
1176
|
+
throw new Error(`Reviewer Role not found: ${task.id}/${round.reviewerRoleName}.`);
|
|
1177
|
+
}
|
|
1178
|
+
const snapshotCommits = new Map((taskScope
|
|
1179
|
+
? round.taskCandidate?.projects ?? []
|
|
1180
|
+
: candidate.gitSnapshot.projects).map(({ projectId, commit }) => [projectId, commit]));
|
|
1181
|
+
const frozenEntries = taskScope
|
|
1182
|
+
? task.projectBindings.map((binding) => {
|
|
1183
|
+
const commit = snapshotCommits.get(binding.projectId);
|
|
1184
|
+
if (commit === undefined) {
|
|
1185
|
+
throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
|
|
1186
|
+
}
|
|
1187
|
+
const project = requireProject(this.store, binding.projectId);
|
|
1188
|
+
const identity = worktreeIdentity(taskSegment, round.id);
|
|
1189
|
+
return {
|
|
1190
|
+
projectId: binding.projectId,
|
|
1191
|
+
directory: binding.directory,
|
|
1192
|
+
access: "write",
|
|
1193
|
+
path: join(this.#projectContainer(project.name), identity.directory),
|
|
1194
|
+
branch: identity.branch,
|
|
1195
|
+
baseRef: commit,
|
|
1196
|
+
baseCommit: commit
|
|
1197
|
+
};
|
|
1198
|
+
})
|
|
1199
|
+
: develop.entries.map((entry) => {
|
|
1200
|
+
const commit = snapshotCommits.get(entry.projectId);
|
|
1201
|
+
if (commit === undefined) {
|
|
1202
|
+
throw new Error(`Candidate snapshot Project is missing: ${entry.projectId}.`);
|
|
1203
|
+
}
|
|
1204
|
+
return { ...entry, baseRef: commit, baseCommit: commit };
|
|
1205
|
+
});
|
|
1206
|
+
if (taskScope && snapshotCommits.size !== task.projectBindings.length) {
|
|
1207
|
+
throw new Error(`Task Review candidate Project scope changed: ${round.id}.`);
|
|
1208
|
+
}
|
|
1209
|
+
const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
|
|
1210
|
+
const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
|
|
1211
|
+
const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round.id);
|
|
1212
|
+
const retained = new Map();
|
|
1213
|
+
const missing = new Set();
|
|
1214
|
+
const adopted = existing?.root === reviewRoot;
|
|
1215
|
+
if (existing !== null) {
|
|
1216
|
+
if (!adopted) {
|
|
1217
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace root changed: ${round.id}.`);
|
|
1218
|
+
}
|
|
1219
|
+
if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, existing)) {
|
|
1220
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1027
1221
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
physical = await this.git.inspect(entry.path, "HEAD");
|
|
1222
|
+
if (existing.entries.length !== expectedEntries.size) {
|
|
1223
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}.`);
|
|
1031
1224
|
}
|
|
1032
|
-
|
|
1225
|
+
for (const entry of existing.entries) {
|
|
1226
|
+
const source = expectedEntries.get(entry.projectId);
|
|
1227
|
+
if (source === undefined) {
|
|
1228
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}/${entry.projectId}.`);
|
|
1229
|
+
}
|
|
1230
|
+
if (!sameCommit(entry.baseCommit, source.baseCommit)) {
|
|
1231
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace baseCommit record mismatch for ${round.id}/${entry.projectId}: `
|
|
1232
|
+
+ `expected ${source.baseCommit}, recorded ${entry.baseCommit}.`);
|
|
1233
|
+
}
|
|
1234
|
+
if (entry.directory !== source.directory
|
|
1235
|
+
|| entry.access !== "write"
|
|
1236
|
+
|| !sameCommit(entry.baseRef, source.baseCommit)) {
|
|
1237
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
|
|
1238
|
+
}
|
|
1239
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1240
|
+
const identity = worktreeIdentity(taskSegment, round.id);
|
|
1241
|
+
const expectedPath = join(this.#projectContainer(project.name), identity.directory);
|
|
1242
|
+
if (entry.path !== expectedPath || entry.branch !== identity.branch) {
|
|
1243
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
|
|
1244
|
+
}
|
|
1245
|
+
let physical;
|
|
1033
1246
|
try {
|
|
1034
|
-
await
|
|
1247
|
+
physical = await this.git.inspect(entry.path, "HEAD");
|
|
1035
1248
|
}
|
|
1036
|
-
catch (
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
continue;
|
|
1249
|
+
catch (error) {
|
|
1250
|
+
try {
|
|
1251
|
+
await lstat(entry.path);
|
|
1040
1252
|
}
|
|
1253
|
+
catch (probeError) {
|
|
1254
|
+
if (isMissingPath(probeError)) {
|
|
1255
|
+
missing.add(entry.projectId);
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace cannot be inspected for ${round.id}/${entry.projectId}: `
|
|
1260
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1041
1261
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
if (branch !== identity.branch) {
|
|
1062
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed branch mismatch for ${round.id}/${entry.projectId}: `
|
|
1063
|
-
+ `expected ${identity.branch}, physical ${branch}.`);
|
|
1064
|
-
}
|
|
1065
|
-
let descendsFromBase;
|
|
1066
|
-
try {
|
|
1067
|
-
descendsFromBase = await this.git.isAncestor(project.path, entry.baseCommit, physical.baseCommit);
|
|
1068
|
-
}
|
|
1069
|
-
catch (error) {
|
|
1070
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
|
|
1071
|
-
+ `${round.id}/${entry.projectId}: `
|
|
1072
|
-
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1073
|
-
}
|
|
1074
|
-
if (!descendsFromBase) {
|
|
1075
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
|
|
1076
|
-
+ `${round.id}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
|
|
1077
|
-
+ `physical HEAD ${physical.baseCommit}.`);
|
|
1078
|
-
}
|
|
1079
|
-
retained.set(entry.projectId, { project, entry });
|
|
1080
|
-
}
|
|
1081
|
-
if (adopted && missing.size === 0) {
|
|
1082
|
-
try {
|
|
1083
|
-
await ensureWorkspaceView(reviewRoot, existing.entries);
|
|
1084
|
-
}
|
|
1085
|
-
catch (error) {
|
|
1086
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
|
|
1087
|
-
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1088
|
-
}
|
|
1089
|
-
if (round.workspace === undefined) {
|
|
1090
|
-
this.store.saveReviewRound(task.id, attachReviewRoundWorkspace(round, existing));
|
|
1091
|
-
}
|
|
1092
|
-
return existing;
|
|
1093
|
-
}
|
|
1094
|
-
// The durable owner record can outlive a controller crash before the
|
|
1095
|
-
// physical review worktrees were adopted. Recreate only missing
|
|
1096
|
-
// physical entries; existing reviewer diagnostics remain attached to
|
|
1097
|
-
// their frozen Candidate provenance.
|
|
1098
|
-
}
|
|
1099
|
-
const prepared = [];
|
|
1100
|
-
try {
|
|
1101
|
-
const sources = adopted
|
|
1102
|
-
? frozenEntries.filter((source) => missing.has(source.projectId))
|
|
1103
|
-
: frozenEntries;
|
|
1104
|
-
for (const source of sources) {
|
|
1105
|
-
const project = requireProject(this.store, source.projectId);
|
|
1106
|
-
const physical = await this.git.ensureWorktree({
|
|
1107
|
-
repositoryPath: project.path,
|
|
1108
|
-
container: this.#projectContainer(project.name),
|
|
1109
|
-
taskSegment,
|
|
1110
|
-
roleName: round.id,
|
|
1111
|
-
baseRef: source.baseCommit
|
|
1112
|
-
});
|
|
1113
|
-
const entry = {
|
|
1114
|
-
...source,
|
|
1115
|
-
access: "write",
|
|
1116
|
-
path: physical.path,
|
|
1117
|
-
branch: physical.branch,
|
|
1118
|
-
baseRef: source.baseCommit,
|
|
1119
|
-
baseCommit: source.baseCommit
|
|
1120
|
-
};
|
|
1121
|
-
prepared.push({ project, entry });
|
|
1122
|
-
if (!sameCommit(physical.baseCommit, source.baseCommit)) {
|
|
1123
|
-
if (existing === null) {
|
|
1124
|
-
throw new Error(`ReviewRound workspace baseCommit mismatch for ${round.id}/${source.projectId}: `
|
|
1125
|
-
+ `expected ${source.baseCommit}, physical HEAD ${physical.baseCommit}.`);
|
|
1262
|
+
if (physical.root !== entry.path) {
|
|
1263
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed path mismatch for ${round.id}/${entry.projectId}.`);
|
|
1264
|
+
}
|
|
1265
|
+
const projectRoot = await this.git.inspect(project.path, "HEAD");
|
|
1266
|
+
if (physical.gitDirectory !== projectRoot.gitDirectory) {
|
|
1267
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed Project mismatch for ${round.id}/${entry.projectId}.`);
|
|
1268
|
+
}
|
|
1269
|
+
let branch;
|
|
1270
|
+
try {
|
|
1271
|
+
branch = await this.git.headRef(entry.path);
|
|
1272
|
+
}
|
|
1273
|
+
catch (error) {
|
|
1274
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace branch cannot be verified for `
|
|
1275
|
+
+ `${round.id}/${entry.projectId}: `
|
|
1276
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1277
|
+
}
|
|
1278
|
+
if (branch !== identity.branch) {
|
|
1279
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed branch mismatch for ${round.id}/${entry.projectId}: `
|
|
1280
|
+
+ `expected ${identity.branch}, physical ${branch}.`);
|
|
1126
1281
|
}
|
|
1127
1282
|
let descendsFromBase;
|
|
1128
1283
|
try {
|
|
1129
|
-
descendsFromBase = await this.git.isAncestor(project.path,
|
|
1284
|
+
descendsFromBase = await this.git.isAncestor(project.path, entry.baseCommit, physical.baseCommit);
|
|
1130
1285
|
}
|
|
1131
1286
|
catch (error) {
|
|
1132
1287
|
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
|
|
1133
|
-
+ `${round.id}/${
|
|
1288
|
+
+ `${round.id}/${entry.projectId}: `
|
|
1134
1289
|
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1135
1290
|
}
|
|
1136
1291
|
if (!descendsFromBase) {
|
|
1137
1292
|
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
|
|
1138
|
-
+ `${round.id}/${
|
|
1293
|
+
+ `${round.id}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
|
|
1139
1294
|
+ `physical HEAD ${physical.baseCommit}.`);
|
|
1140
1295
|
}
|
|
1296
|
+
retained.set(entry.projectId, { project, entry });
|
|
1141
1297
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const retainedEntry = retained.get(source.projectId)?.entry;
|
|
1147
|
-
if (retainedEntry !== undefined)
|
|
1148
|
-
return retainedEntry;
|
|
1149
|
-
const preparedEntry = preparedByProject.get(source.projectId);
|
|
1150
|
-
if (preparedEntry === undefined) {
|
|
1151
|
-
throw new Error(`ReviewRound workspace Project could not be reconstructed: `
|
|
1152
|
-
+ `${round.id}/${source.projectId}.`);
|
|
1298
|
+
if (adopted && missing.size === 0) {
|
|
1299
|
+
try {
|
|
1300
|
+
await ensureWorkspaceView(reviewRoot, existing.entries);
|
|
1301
|
+
this.#registerWorkspace(existing);
|
|
1153
1302
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
|
|
1305
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1306
|
+
}
|
|
1307
|
+
if (round.workspace === undefined) {
|
|
1308
|
+
this.store.saveReviewRound(task.id, attachReviewRoundWorkspace(round, existing));
|
|
1309
|
+
}
|
|
1310
|
+
return existing;
|
|
1311
|
+
}
|
|
1312
|
+
// The durable owner record can outlive a controller crash before the
|
|
1313
|
+
// physical review worktrees were adopted. Recreate only missing
|
|
1314
|
+
// physical entries; existing reviewer diagnostics remain attached to
|
|
1315
|
+
// their frozen Candidate provenance.
|
|
1316
|
+
}
|
|
1317
|
+
const prepared = [];
|
|
1157
1318
|
try {
|
|
1158
|
-
|
|
1319
|
+
const sources = adopted
|
|
1320
|
+
? frozenEntries.filter((source) => missing.has(source.projectId))
|
|
1321
|
+
: frozenEntries;
|
|
1322
|
+
for (const source of sources) {
|
|
1323
|
+
const project = requireProject(this.store, source.projectId);
|
|
1324
|
+
const physical = await this.git.ensureWorktree({
|
|
1325
|
+
repositoryPath: project.path,
|
|
1326
|
+
container: this.#projectContainer(project.name),
|
|
1327
|
+
taskSegment,
|
|
1328
|
+
roleName: round.id,
|
|
1329
|
+
baseRef: source.baseCommit
|
|
1330
|
+
});
|
|
1331
|
+
const entry = {
|
|
1332
|
+
...source,
|
|
1333
|
+
access: "write",
|
|
1334
|
+
path: physical.path,
|
|
1335
|
+
branch: physical.branch,
|
|
1336
|
+
baseRef: source.baseCommit,
|
|
1337
|
+
baseCommit: source.baseCommit
|
|
1338
|
+
};
|
|
1339
|
+
prepared.push({ project, entry });
|
|
1340
|
+
if (!sameCommit(physical.baseCommit, source.baseCommit)) {
|
|
1341
|
+
if (existing === null) {
|
|
1342
|
+
throw new Error(`ReviewRound workspace baseCommit mismatch for ${round.id}/${source.projectId}: `
|
|
1343
|
+
+ `expected ${source.baseCommit}, physical HEAD ${physical.baseCommit}.`);
|
|
1344
|
+
}
|
|
1345
|
+
let descendsFromBase;
|
|
1346
|
+
try {
|
|
1347
|
+
descendsFromBase = await this.git.isAncestor(project.path, source.baseCommit, physical.baseCommit);
|
|
1348
|
+
}
|
|
1349
|
+
catch (error) {
|
|
1350
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
|
|
1351
|
+
+ `${round.id}/${source.projectId}: `
|
|
1352
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1353
|
+
}
|
|
1354
|
+
if (!descendsFromBase) {
|
|
1355
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
|
|
1356
|
+
+ `${round.id}/${source.projectId}: expected ancestor ${source.baseCommit}, `
|
|
1357
|
+
+ `physical HEAD ${physical.baseCommit}.`);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const preparedByProject = new Map(prepared.map(({ project, entry }) => [project.id, entry]));
|
|
1362
|
+
const entries = adopted
|
|
1363
|
+
? frozenEntries.map((source) => {
|
|
1364
|
+
const retainedEntry = retained.get(source.projectId)?.entry;
|
|
1365
|
+
if (retainedEntry !== undefined)
|
|
1366
|
+
return retainedEntry;
|
|
1367
|
+
const preparedEntry = preparedByProject.get(source.projectId);
|
|
1368
|
+
if (preparedEntry === undefined) {
|
|
1369
|
+
throw new Error(`ReviewRound workspace Project could not be reconstructed: `
|
|
1370
|
+
+ `${round.id}/${source.projectId}.`);
|
|
1371
|
+
}
|
|
1372
|
+
return preparedEntry;
|
|
1373
|
+
})
|
|
1374
|
+
: prepared.map(({ entry }) => entry);
|
|
1375
|
+
try {
|
|
1376
|
+
await ensureWorkspaceView(reviewRoot, entries);
|
|
1377
|
+
}
|
|
1378
|
+
catch (error) {
|
|
1379
|
+
if (existing !== null) {
|
|
1380
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
|
|
1381
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1382
|
+
}
|
|
1383
|
+
throw error;
|
|
1384
|
+
}
|
|
1385
|
+
const stored = existing ?? createManagedWorkspace({
|
|
1386
|
+
owner: { type: "review-round", taskId: task.id, reviewRoundId: round.id },
|
|
1387
|
+
root: reviewRoot,
|
|
1388
|
+
entries
|
|
1389
|
+
}, this.now());
|
|
1390
|
+
this.#registerWorkspace(stored);
|
|
1391
|
+
return this.store.transaction((tx) => {
|
|
1392
|
+
const currentRound = tx.getReviewRound(task.id, round.id);
|
|
1393
|
+
const currentItem = tx.getWorkItem(task.id, item.id);
|
|
1394
|
+
if (currentRound === null || currentRound.status !== "pending"
|
|
1395
|
+
|| currentItem === null
|
|
1396
|
+
|| !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate)) {
|
|
1397
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound changed while preparing its workspace: ${round.id}.`);
|
|
1398
|
+
}
|
|
1399
|
+
if (tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
|
|
1400
|
+
throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role has an active Run: ${task.id}/${reviewer.name}.`);
|
|
1401
|
+
}
|
|
1402
|
+
const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
|
|
1403
|
+
if (existing !== null
|
|
1404
|
+
? currentWorkspace === null || !sameManagedWorkspace(currentWorkspace, existing)
|
|
1405
|
+
: currentWorkspace !== null && !sameManagedWorkspace(currentWorkspace, stored)) {
|
|
1406
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace changed: ${task.id}/${round.id}.`);
|
|
1407
|
+
}
|
|
1408
|
+
const latestReviewer = tx.getRole(task.id, reviewer.name);
|
|
1409
|
+
if (latestReviewer === null) {
|
|
1410
|
+
throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role not found: ${task.id}/${reviewer.name}.`);
|
|
1411
|
+
}
|
|
1412
|
+
if (currentRound.workspace !== undefined
|
|
1413
|
+
&& !sameManagedWorkspace(currentRound.workspace, stored)) {
|
|
1414
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1415
|
+
}
|
|
1416
|
+
const timestamp = this.now();
|
|
1417
|
+
if (currentWorkspace === null)
|
|
1418
|
+
tx.saveManagedWorkspace(stored);
|
|
1419
|
+
if (currentRound.workspace === undefined) {
|
|
1420
|
+
tx.saveReviewRound(task.id, attachReviewRoundWorkspace(currentRound, stored));
|
|
1421
|
+
}
|
|
1422
|
+
if (latestReviewer.workspace !== stored.root) {
|
|
1423
|
+
retireWorkspaceBoundSession(tx, task.id, latestReviewer.name, timestamp);
|
|
1424
|
+
tx.saveRole(task.id, updateRole(latestReviewer, { workspace: stored.root }, timestamp));
|
|
1425
|
+
}
|
|
1426
|
+
return stored;
|
|
1427
|
+
});
|
|
1159
1428
|
}
|
|
1160
1429
|
catch (error) {
|
|
1161
|
-
|
|
1162
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
|
|
1163
|
-
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1164
|
-
}
|
|
1430
|
+
await this.#discardUnadoptedEntries(task, taskSegment, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
|
|
1165
1431
|
throw error;
|
|
1166
1432
|
}
|
|
1167
|
-
const stored = existing ?? createManagedWorkspace({
|
|
1168
|
-
owner: { type: "review-round", taskId: task.id, reviewRoundId: round.id },
|
|
1169
|
-
root: reviewRoot,
|
|
1170
|
-
entries
|
|
1171
|
-
}, this.now());
|
|
1172
|
-
return this.store.transaction((tx) => {
|
|
1173
|
-
const currentRound = tx.getReviewRound(task.id, round.id);
|
|
1174
|
-
const currentItem = tx.getWorkItem(task.id, item.id);
|
|
1175
|
-
if (currentRound === null || currentRound.status !== "pending"
|
|
1176
|
-
|| currentItem === null
|
|
1177
|
-
|| !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate)) {
|
|
1178
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound changed while preparing its workspace: ${round.id}.`);
|
|
1179
|
-
}
|
|
1180
|
-
if (tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
|
|
1181
|
-
throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role has an active Run: ${task.id}/${reviewer.name}.`);
|
|
1182
|
-
}
|
|
1183
|
-
const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
|
|
1184
|
-
if (existing !== null
|
|
1185
|
-
? currentWorkspace === null || !sameManagedWorkspace(currentWorkspace, existing)
|
|
1186
|
-
: currentWorkspace !== null && !sameManagedWorkspace(currentWorkspace, stored)) {
|
|
1187
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace changed: ${task.id}/${round.id}.`);
|
|
1188
|
-
}
|
|
1189
|
-
const latestReviewer = tx.getRole(task.id, reviewer.name);
|
|
1190
|
-
if (latestReviewer === null) {
|
|
1191
|
-
throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role not found: ${task.id}/${reviewer.name}.`);
|
|
1192
|
-
}
|
|
1193
|
-
if (currentRound.workspace !== undefined
|
|
1194
|
-
&& !sameManagedWorkspace(currentRound.workspace, stored)) {
|
|
1195
|
-
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1196
|
-
}
|
|
1197
|
-
const timestamp = this.now();
|
|
1198
|
-
if (currentWorkspace === null)
|
|
1199
|
-
tx.saveManagedWorkspace(stored);
|
|
1200
|
-
if (currentRound.workspace === undefined) {
|
|
1201
|
-
tx.saveReviewRound(task.id, attachReviewRoundWorkspace(currentRound, stored));
|
|
1202
|
-
}
|
|
1203
|
-
if (latestReviewer.workspace !== stored.root) {
|
|
1204
|
-
retireWorkspaceBoundSession(tx, task.id, latestReviewer.name, timestamp);
|
|
1205
|
-
tx.saveRole(task.id, updateRole(latestReviewer, { workspace: stored.root }, timestamp));
|
|
1206
|
-
}
|
|
1207
|
-
return stored;
|
|
1208
|
-
});
|
|
1209
1433
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
throw error;
|
|
1434
|
+
finally {
|
|
1435
|
+
release();
|
|
1213
1436
|
}
|
|
1214
1437
|
}
|
|
1215
1438
|
async inspectReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
@@ -1238,7 +1461,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1238
1461
|
if (round.workspace === undefined || !isDeepStrictEqual(round.workspace, workspace)) {
|
|
1239
1462
|
throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1240
1463
|
}
|
|
1241
|
-
return this.#snapshotReviewWorkspaceEntries(round.id, workspace);
|
|
1464
|
+
return this.#snapshotReviewWorkspaceEntries(round.id, workspace, round.reviewBaseCommit);
|
|
1242
1465
|
}
|
|
1243
1466
|
/**
|
|
1244
1467
|
* Snapshot the exact workspace recorded on one Reviewer Run. Panel Lanes
|
|
@@ -1267,7 +1490,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1267
1490
|
|| !isDeepStrictEqual(round.workspace, run.workspace)) {
|
|
1268
1491
|
throw new Error(`Review Run workspace owner does not match its ReviewRound: ${run.id}.`);
|
|
1269
1492
|
}
|
|
1270
|
-
return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
|
|
1493
|
+
return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace, round.reviewBaseCommit);
|
|
1271
1494
|
}
|
|
1272
1495
|
if (run.workspace.owner.type !== "execution-lane"
|
|
1273
1496
|
|| run.workspace.owner.taskId !== taskId
|
|
@@ -1292,7 +1515,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1292
1515
|
|| run.workspace.entries.some(({ access }) => access !== "write")) {
|
|
1293
1516
|
throw new Error(`Review Run Lane workspace lineage is not exact: ${run.id}.`);
|
|
1294
1517
|
}
|
|
1295
|
-
return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
|
|
1518
|
+
return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace, round.reviewBaseCommit);
|
|
1296
1519
|
}
|
|
1297
1520
|
async inspectExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
|
|
1298
1521
|
const task = requireTask(this.store, taskId);
|
|
@@ -1343,6 +1566,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1343
1566
|
removed ||= result === "removed";
|
|
1344
1567
|
}
|
|
1345
1568
|
await removeWorkspaceView(workspace.root);
|
|
1569
|
+
this.#resourceRegistrar().markWorkspaceDeleted(workspace);
|
|
1346
1570
|
this.store.transaction((tx) => {
|
|
1347
1571
|
const currentRound = tx.getReviewRound(task.id, round.id);
|
|
1348
1572
|
const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
|
|
@@ -1408,6 +1632,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1408
1632
|
removed ||= result === "removed";
|
|
1409
1633
|
}
|
|
1410
1634
|
await removeWorkspaceView(workspace.root);
|
|
1635
|
+
this.#resourceRegistrar().markWorkspaceDeleted(workspace);
|
|
1411
1636
|
try {
|
|
1412
1637
|
this.#recordWorkspaceRemoval(task, workspace, this.#fallbackWorkspace(), {
|
|
1413
1638
|
workItemId: item.id,
|
|
@@ -1469,6 +1694,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1469
1694
|
}
|
|
1470
1695
|
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
1471
1696
|
await removeWorkspaceView(main.root);
|
|
1697
|
+
this.#resourceRegistrar().markWorkspaceDeleted(main);
|
|
1472
1698
|
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
1473
1699
|
this.#recordWorkspaceRemoval(task, main, this.#fallbackWorkspace());
|
|
1474
1700
|
}
|
|
@@ -1490,6 +1716,28 @@ export class FileTaskWorkspacePreparer {
|
|
|
1490
1716
|
if (!["draft", "active"].includes(task.status)) {
|
|
1491
1717
|
throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
|
|
1492
1718
|
}
|
|
1719
|
+
// The rebuild holds every touched Project's maintenance fence for its
|
|
1720
|
+
// whole duration, so the Controller defers preparation and no other
|
|
1721
|
+
// maintenance interleaves. The resume path may clean up legacy
|
|
1722
|
+
// worktrees/refs in any Project; the fresh path touches its bound
|
|
1723
|
+
// Projects plus any still holding the Task's legacy refs.
|
|
1724
|
+
const projectIds = task.workspaceIdentity !== undefined
|
|
1725
|
+
? this.store.listProjects().map(({ id }) => id)
|
|
1726
|
+
: [
|
|
1727
|
+
...new Set([
|
|
1728
|
+
...task.projectBindings.map(({ projectId }) => projectId),
|
|
1729
|
+
...(await this.listLegacyTaskRefs(task.id)).map(({ projectId }) => projectId)
|
|
1730
|
+
])
|
|
1731
|
+
];
|
|
1732
|
+
const releaseMaintenance = acquireProjectMaintenanceLocks(this.home, projectIds);
|
|
1733
|
+
try {
|
|
1734
|
+
return await this.#rebuildTaskWorkspaceLocked(task, options);
|
|
1735
|
+
}
|
|
1736
|
+
finally {
|
|
1737
|
+
releaseMaintenance();
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
async #rebuildTaskWorkspaceLocked(task, options) {
|
|
1493
1741
|
if (task.workspaceIdentity !== undefined) {
|
|
1494
1742
|
const current = this.store.getTaskWorkspace(task.id);
|
|
1495
1743
|
if (current !== null && current.owner.type === "task") {
|
|
@@ -1500,6 +1748,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
1500
1748
|
// ("dirty") status and can no longer be removed cleanly.
|
|
1501
1749
|
await this.#removeLegacyWorktrees(task, this.store.listProjects().map((project) => project.id));
|
|
1502
1750
|
const archived = await this.#archiveLegacyRefs(task);
|
|
1751
|
+
// Reclaim token worktrees/branches a crashed prepare or rebuild left
|
|
1752
|
+
// behind without a catalog record, so they do not accumulate.
|
|
1753
|
+
await this.#reclaimOrphanedTaskWorktrees(task);
|
|
1503
1754
|
return { task: requireTask(this.store, task.id), archived, resumed: true };
|
|
1504
1755
|
}
|
|
1505
1756
|
assertTaskHasNoEvidence(this.store, task.id);
|
|
@@ -1511,6 +1762,12 @@ export class FileTaskWorkspacePreparer {
|
|
|
1511
1762
|
&& await this.#inspectLegacyTaskEntries(task, existing.entries) === "dirty") {
|
|
1512
1763
|
throw new Error(`Task workspace is dirty and blocks the rebuild: ${task.id}.`);
|
|
1513
1764
|
}
|
|
1765
|
+
// Reclaim token worktrees/branches a crashed prepare or rebuild left
|
|
1766
|
+
// behind without a catalog record, before minting a new identity. A hard
|
|
1767
|
+
// crash after `ensureWorktree` but before the Task/catalog transaction
|
|
1768
|
+
// leaves the Task unbound; without this reaping the fresh path would
|
|
1769
|
+
// report success and leave the orphaned token worktree/branch behind.
|
|
1770
|
+
await this.#reclaimOrphanedTaskWorktrees(task);
|
|
1514
1771
|
// Resolve verified remote SHAs before any Git side effect, mirroring the
|
|
1515
1772
|
// first-prepare pinning: a remote default Project is fetched and its
|
|
1516
1773
|
// exact advertised SHA is pinned; a local or explicit ref is validated.
|
|
@@ -1579,6 +1836,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1579
1836
|
root,
|
|
1580
1837
|
entries: prepared.map(({ entry }) => entry)
|
|
1581
1838
|
}, this.now());
|
|
1839
|
+
this.#registerWorkspace(workspace);
|
|
1582
1840
|
// Switch the durable record only now that every new ref/worktree exists.
|
|
1583
1841
|
this.store.transaction((tx) => {
|
|
1584
1842
|
const latest = requireTask(tx, task.id);
|
|
@@ -1635,7 +1893,16 @@ export class FileTaskWorkspacePreparer {
|
|
|
1635
1893
|
async listLegacyTaskRefs(taskId) {
|
|
1636
1894
|
const found = [];
|
|
1637
1895
|
for (const project of this.store.listProjects()) {
|
|
1638
|
-
|
|
1896
|
+
let refs;
|
|
1897
|
+
try {
|
|
1898
|
+
refs = await this.git.listRefs(project.path, "refs/heads/yui/");
|
|
1899
|
+
}
|
|
1900
|
+
catch (error) {
|
|
1901
|
+
// A deleted external checkout has no refs to scan; skip it.
|
|
1902
|
+
if (isMissingPath(error))
|
|
1903
|
+
continue;
|
|
1904
|
+
throw error;
|
|
1905
|
+
}
|
|
1639
1906
|
for (const ref of refs) {
|
|
1640
1907
|
if (!isLegacyTaskRef(ref))
|
|
1641
1908
|
continue;
|
|
@@ -1653,26 +1920,66 @@ export class FileTaskWorkspacePreparer {
|
|
|
1653
1920
|
* Task are refused: their worktrees may still be live. Terminal and
|
|
1654
1921
|
* unknown-owner refs are archived; an already-archived or missing ref is
|
|
1655
1922
|
* simply absent on retry.
|
|
1923
|
+
*
|
|
1924
|
+
* Every registered worktree on a to-be-archived ref is preflighted before
|
|
1925
|
+
* the first side effect: a dirty or unidentifiable one fails the whole
|
|
1926
|
+
* archive with nothing created, deleted, or removed. A same-repo worktree
|
|
1927
|
+
* this Home does not manage that still has the ref checked out fails the
|
|
1928
|
+
* archive closed as well, so deleting the ref cannot strand it.
|
|
1656
1929
|
*/
|
|
1657
1930
|
async archiveLegacyTaskRefs(taskId) {
|
|
1658
1931
|
const home = this.store.getHomeIdentity();
|
|
1659
1932
|
const refused = [];
|
|
1660
1933
|
const archived = [];
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1934
|
+
// The pre-lock scan discovers ONLY the sorted Project lock set. The pending
|
|
1935
|
+
// set is rebuilt under the fence (below) by re-listing refs and
|
|
1936
|
+
// re-classifying their owners, so a Task that transitions terminal ->
|
|
1937
|
+
// active between the scan and the lock is refused rather than archived.
|
|
1938
|
+
const scanned = await this.listLegacyTaskRefs(taskId);
|
|
1939
|
+
const projectIds = [...new Set(scanned.map(({ projectId }) => projectId))].sort();
|
|
1940
|
+
// The fence covers preflight and archive alike: a concurrent rebuild
|
|
1941
|
+
// must not remove a worktree between its inspection and its ref's
|
|
1942
|
+
// archival.
|
|
1943
|
+
const releaseMaintenance = projectIds.length === 0
|
|
1944
|
+
? () => { }
|
|
1945
|
+
: acquireProjectMaintenanceLocks(this.home, projectIds);
|
|
1946
|
+
try {
|
|
1947
|
+
const locked = new Set(projectIds);
|
|
1948
|
+
const pending = [];
|
|
1949
|
+
for (const entry of await this.listLegacyTaskRefs(taskId)) {
|
|
1950
|
+
if (!locked.has(entry.projectId))
|
|
1951
|
+
continue; // added after the pre-lock scan; not fenced.
|
|
1952
|
+
const owner = this.store.getTask(entry.taskId);
|
|
1953
|
+
if (owner !== null && ["draft", "active"].includes(owner.status)) {
|
|
1954
|
+
refused.push(`${entry.projectId}:${entry.ref}`);
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
pending.push({
|
|
1958
|
+
project: requireProject(this.store, entry.projectId),
|
|
1959
|
+
taskId: entry.taskId,
|
|
1960
|
+
ref: entry.ref,
|
|
1961
|
+
archiveRef: taskArchiveRef(home.homeId, entry.ref)
|
|
1962
|
+
});
|
|
1666
1963
|
}
|
|
1667
|
-
const
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1964
|
+
for (const target of pending) {
|
|
1965
|
+
await this.#assertLegacyRefWorktreeArchivable(target);
|
|
1966
|
+
}
|
|
1967
|
+
for (const target of pending) {
|
|
1968
|
+
// Re-validate under the fence: a Task reopened after classification
|
|
1969
|
+
// but before this point must not have its ref deleted.
|
|
1970
|
+
const current = this.store.getTask(target.taskId);
|
|
1971
|
+
if (current !== null && ["draft", "active"].includes(current.status)) {
|
|
1972
|
+
refused.push(`${target.project.id}:${target.ref}`);
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
await this.#archiveLegacyRef(target);
|
|
1976
|
+
archived.push(`${target.project.id}:${target.ref}`);
|
|
1977
|
+
}
|
|
1978
|
+
return { archived, refused };
|
|
1979
|
+
}
|
|
1980
|
+
finally {
|
|
1981
|
+
releaseMaintenance();
|
|
1674
1982
|
}
|
|
1675
|
-
return { archived, refused };
|
|
1676
1983
|
}
|
|
1677
1984
|
async #inspectEntries(taskSegment, roleName, entries) {
|
|
1678
1985
|
if (entries.length === 0)
|
|
@@ -1692,8 +1999,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
1692
1999
|
}
|
|
1693
2000
|
return found ? "clean" : "missing";
|
|
1694
2001
|
}
|
|
1695
|
-
async #snapshotReviewWorkspaceEntries(reviewRoundId, workspace) {
|
|
2002
|
+
async #snapshotReviewWorkspaceEntries(reviewRoundId, workspace, reviewBaseCommit) {
|
|
1696
2003
|
const changed = [];
|
|
2004
|
+
let dirty = false;
|
|
1697
2005
|
for (const entry of workspace.entries) {
|
|
1698
2006
|
if (await this.git.headRef(entry.path) !== entry.branch) {
|
|
1699
2007
|
throw new Error(`Review Project workspace left its managed branch: ${reviewRoundId}/${entry.projectId}.`);
|
|
@@ -1707,11 +2015,22 @@ export class FileTaskWorkspacePreparer {
|
|
|
1707
2015
|
}
|
|
1708
2016
|
if (head !== entry.baseCommit)
|
|
1709
2017
|
changed.push(head);
|
|
2018
|
+
if (!await this.git.isClean(entry.path))
|
|
2019
|
+
dirty = true;
|
|
1710
2020
|
}
|
|
1711
2021
|
if (changed.length > 1) {
|
|
1712
2022
|
throw new Error(`Review workspace has diagnostic commits in multiple Projects; preserve it for Leader routing: ${reviewRoundId}.`);
|
|
1713
2023
|
}
|
|
1714
|
-
|
|
2024
|
+
// A dirty worktree has uncommitted diagnostics: no single commit captures
|
|
2025
|
+
// the tree the checks ran on, so no evidenceCommit can attest it and the
|
|
2026
|
+
// queue must re-run the gate. A clean worktree attests that checks ran on
|
|
2027
|
+
// the recorded tree: the frozen base when the reviewer made no commits, or
|
|
2028
|
+
// the reviewer's single diagnostic commit otherwise.
|
|
2029
|
+
if (dirty)
|
|
2030
|
+
return {};
|
|
2031
|
+
return changed.length === 0
|
|
2032
|
+
? { evidenceCommit: reviewBaseCommit }
|
|
2033
|
+
: { evidenceCommit: changed[0] };
|
|
1715
2034
|
}
|
|
1716
2035
|
#projectContainer(projectName) {
|
|
1717
2036
|
return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
|
|
@@ -1753,6 +2072,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1753
2072
|
if (removal === "dirty") {
|
|
1754
2073
|
throw new Error(`Unadopted managed worktree is dirty and was retained at ${entry.path}; inspect it and retry.`);
|
|
1755
2074
|
}
|
|
2075
|
+
this.#resourceRegistrar().markPathsDeleted([entry.path]);
|
|
1756
2076
|
}
|
|
1757
2077
|
}
|
|
1758
2078
|
/**
|
|
@@ -1769,7 +2089,16 @@ export class FileTaskWorkspacePreparer {
|
|
|
1769
2089
|
const seen = new Set();
|
|
1770
2090
|
const archived = [];
|
|
1771
2091
|
for (const project of projects) {
|
|
1772
|
-
|
|
2092
|
+
let refs;
|
|
2093
|
+
try {
|
|
2094
|
+
refs = await this.git.listRefs(project.path, `refs/heads/yui/${task.id}/`);
|
|
2095
|
+
}
|
|
2096
|
+
catch (error) {
|
|
2097
|
+
// A deleted external checkout has no refs to archive; skip it.
|
|
2098
|
+
if (isMissingPath(error))
|
|
2099
|
+
continue;
|
|
2100
|
+
throw error;
|
|
2101
|
+
}
|
|
1773
2102
|
for (const ref of refs) {
|
|
1774
2103
|
const ownerTaskId = ref.slice("refs/heads/yui/".length).split("/")[0];
|
|
1775
2104
|
if (ownerTaskId !== task.id || !isLegacyTaskRef(ref))
|
|
@@ -1778,9 +2107,10 @@ export class FileTaskWorkspacePreparer {
|
|
|
1778
2107
|
if (seen.has(key))
|
|
1779
2108
|
continue;
|
|
1780
2109
|
seen.add(key);
|
|
1781
|
-
await this
|
|
1782
|
-
|
|
1783
|
-
|
|
2110
|
+
await this.#archiveLegacyRef({
|
|
2111
|
+
project,
|
|
2112
|
+
taskId: task.id,
|
|
2113
|
+
ref,
|
|
1784
2114
|
archiveRef: taskArchiveRef(home.homeId, ref)
|
|
1785
2115
|
});
|
|
1786
2116
|
archived.push(key);
|
|
@@ -1788,6 +2118,77 @@ export class FileTaskWorkspacePreparer {
|
|
|
1788
2118
|
}
|
|
1789
2119
|
return archived;
|
|
1790
2120
|
}
|
|
2121
|
+
/**
|
|
2122
|
+
* The recorded worktree of a legacy Task ref, when the ref is the Task's
|
|
2123
|
+
* main branch. The legacy layout registers exactly one worktree per Task
|
|
2124
|
+
* and Project (`<taskId>/main` on `yui/<taskId>/main`); every other legacy
|
|
2125
|
+
* ref has no registered worktree and is archived directly.
|
|
2126
|
+
*/
|
|
2127
|
+
#legacyRefWorktree(target) {
|
|
2128
|
+
const identity = worktreeIdentity(target.taskId, MAIN_WORKTREE);
|
|
2129
|
+
if (target.ref !== `refs/heads/${identity.branch}`)
|
|
2130
|
+
return undefined;
|
|
2131
|
+
const container = this.#projectContainer(target.project.name);
|
|
2132
|
+
return {
|
|
2133
|
+
repositoryPath: target.project.path,
|
|
2134
|
+
container,
|
|
2135
|
+
path: join(container, target.taskId, MAIN_WORKTREE),
|
|
2136
|
+
branch: identity.branch,
|
|
2137
|
+
taskSegment: target.taskId,
|
|
2138
|
+
roleName: MAIN_WORKTREE
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Preflight the worktree of a to-be-archived ref: a registered worktree
|
|
2143
|
+
* must be clean (or absent) before the archive may touch any ref. An
|
|
2144
|
+
* unidentifiable worktree makes `inspectRecordedWorktree` throw, which
|
|
2145
|
+
* likewise fails the archive closed.
|
|
2146
|
+
*/
|
|
2147
|
+
async #assertLegacyRefWorktreeArchivable(target) {
|
|
2148
|
+
const worktree = this.#legacyRefWorktree(target);
|
|
2149
|
+
if (worktree === undefined)
|
|
2150
|
+
return;
|
|
2151
|
+
const state = await this.git.inspectRecordedWorktree(worktree);
|
|
2152
|
+
if (state === "dirty") {
|
|
2153
|
+
throw new Error(`Legacy Task worktree is dirty and blocks the archive: ${target.taskId}/${target.project.id}.`);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Archive one legacy ref after removing its registered worktree. The
|
|
2158
|
+
* worktree leaves first (its commit retained in the archive ref), then the
|
|
2159
|
+
* active ref is deleted; a dirty worktree fails the ref closed. Each step
|
|
2160
|
+
* is resumable: a same-commit archive ref resumes, a missing worktree and
|
|
2161
|
+
* an already-deleted source are no-ops.
|
|
2162
|
+
*
|
|
2163
|
+
* Before any mutation, a same-repo worktree this Home does not manage that
|
|
2164
|
+
* still has the ref checked out fails the archive closed: `archiveRef`
|
|
2165
|
+
* deletes the ref with `update-ref -d`, which bypasses git's
|
|
2166
|
+
* worktree-occupancy check and would strand that worktree with an unborn
|
|
2167
|
+
* HEAD. The recorded worktree removed below is excluded from the check.
|
|
2168
|
+
*/
|
|
2169
|
+
async #archiveLegacyRef(target) {
|
|
2170
|
+
const worktree = this.#legacyRefWorktree(target);
|
|
2171
|
+
await this.git.assertNoForeignWorktreeOnRef({
|
|
2172
|
+
repositoryPath: target.project.path,
|
|
2173
|
+
ref: target.ref,
|
|
2174
|
+
excludeWorktreePath: worktree?.path
|
|
2175
|
+
});
|
|
2176
|
+
if (worktree !== undefined) {
|
|
2177
|
+
const removal = await this.git.removeRecordedWorktree({
|
|
2178
|
+
...worktree,
|
|
2179
|
+
retainedRef: target.archiveRef
|
|
2180
|
+
});
|
|
2181
|
+
if (removal === "dirty") {
|
|
2182
|
+
throw new Error(`Legacy Task worktree is dirty and blocks the archive: ${target.taskId}/${target.project.id}.`);
|
|
2183
|
+
}
|
|
2184
|
+
this.#resourceRegistrar().markPathsDeleted([worktree.path]);
|
|
2185
|
+
}
|
|
2186
|
+
await this.git.archiveRef({
|
|
2187
|
+
repositoryPath: target.project.path,
|
|
2188
|
+
sourceRef: target.ref,
|
|
2189
|
+
archiveRef: target.archiveRef
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
1791
2192
|
/**
|
|
1792
2193
|
* Remove the legacy worktrees of a Task (the bare `<taskId>/main` layout).
|
|
1793
2194
|
* A missing worktree is expected on retry and ignored; a dirty one blocks.
|
|
@@ -1809,6 +2210,70 @@ export class FileTaskWorkspacePreparer {
|
|
|
1809
2210
|
if (removal === "dirty") {
|
|
1810
2211
|
throw new Error(`Legacy Task worktree is dirty and blocks the rebuild: ${task.id}/${project.id}.`);
|
|
1811
2212
|
}
|
|
2213
|
+
this.#resourceRegistrar().markPathsDeleted([join(container, task.id, MAIN_WORKTREE)]);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Reclaim token worktrees of this Task that no catalog record owns. A
|
|
2218
|
+
* crashed prepare or rebuild (SIGKILL before the catalog transaction) leaves
|
|
2219
|
+
* a token-bearing worktree and branch behind; a retry mints a fresh token,
|
|
2220
|
+
* so the old one is orphaned. The resume path scans this Home's project
|
|
2221
|
+
* containers for `task-N-<token>` directories and removes any the catalog
|
|
2222
|
+
* no longer owns, reusing the recorded-worktree mechanism: prove the exact
|
|
2223
|
+
* identity, retain the commit in the Home archive, remove the worktree, then
|
|
2224
|
+
* archive and delete its now-unchecked-out branch.
|
|
2225
|
+
*
|
|
2226
|
+
* Only this Home's project containers are scanned, and every candidate must
|
|
2227
|
+
* pass the exact recorded-worktree proof (its branch retained in this
|
|
2228
|
+
* Home's Project repository) before it is removed. A deleted external
|
|
2229
|
+
* checkout orphans the worktree directory, which `removeRecordedWorktree`
|
|
2230
|
+
* removes outright; its branch is gone with the repository and needs no
|
|
2231
|
+
* further action.
|
|
2232
|
+
*/
|
|
2233
|
+
async #reclaimOrphanedTaskWorktrees(task) {
|
|
2234
|
+
const cataloged = new Set(this.store.listManagedWorkspaces(task.id)
|
|
2235
|
+
.flatMap((workspace) => workspace.entries.map(({ path }) => path)));
|
|
2236
|
+
const homeId = this.store.getHomeIdentity().homeId;
|
|
2237
|
+
for (const project of this.store.listProjects()) {
|
|
2238
|
+
const container = this.#projectContainer(project.name);
|
|
2239
|
+
for (const segment of await listTaskTokenSegments(container, task.id)) {
|
|
2240
|
+
const worktreePath = join(container, segment, MAIN_WORKTREE);
|
|
2241
|
+
if (cataloged.has(worktreePath))
|
|
2242
|
+
continue;
|
|
2243
|
+
const branch = worktreeIdentity(segment, MAIN_WORKTREE).branch;
|
|
2244
|
+
const archiveRef = taskArchiveRef(homeId, `refs/heads/${branch}`);
|
|
2245
|
+
const removal = await this.git.removeRecordedWorktree({
|
|
2246
|
+
repositoryPath: project.path,
|
|
2247
|
+
container,
|
|
2248
|
+
path: worktreePath,
|
|
2249
|
+
branch,
|
|
2250
|
+
retainedRef: archiveRef,
|
|
2251
|
+
taskSegment: segment,
|
|
2252
|
+
roleName: MAIN_WORKTREE
|
|
2253
|
+
});
|
|
2254
|
+
if (removal === "dirty") {
|
|
2255
|
+
throw new Error(`Orphaned Task worktree is dirty and blocks the rebuild: ${task.id}/${project.id}.`);
|
|
2256
|
+
}
|
|
2257
|
+
this.#resourceRegistrar().markPathsDeleted([worktreePath]);
|
|
2258
|
+
// The worktree is gone; archive+delete its now-unchecked-out branch.
|
|
2259
|
+
// A deleted external checkout takes the branch with it, so a missing
|
|
2260
|
+
// repository or an already-absent branch is a no-op.
|
|
2261
|
+
let branchExists = false;
|
|
2262
|
+
try {
|
|
2263
|
+
branchExists = await this.git.refExists(project.path, `refs/heads/${branch}`);
|
|
2264
|
+
}
|
|
2265
|
+
catch (error) {
|
|
2266
|
+
if (!isMissingPath(error))
|
|
2267
|
+
throw error;
|
|
2268
|
+
}
|
|
2269
|
+
if (branchExists) {
|
|
2270
|
+
await this.git.archiveRef({
|
|
2271
|
+
repositoryPath: project.path,
|
|
2272
|
+
sourceRef: `refs/heads/${branch}`,
|
|
2273
|
+
archiveRef
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
1812
2277
|
}
|
|
1813
2278
|
}
|
|
1814
2279
|
async #inspectLegacyTaskEntries(task, entries) {
|
|
@@ -1932,6 +2397,17 @@ function executionLaneLineage(store, task, executionGroupId, executionLaneId, hi
|
|
|
1932
2397
|
throw new Error(`ReviewRound not found: ${hint.reviewRoundId}.`);
|
|
1933
2398
|
return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
|
|
1934
2399
|
}
|
|
2400
|
+
// Prefer the active Run's exact WorkItem for this Lane; fall back to the
|
|
2401
|
+
// first queued WorkItem only when no active Run owns the Lane.
|
|
2402
|
+
const activeLaneRun = store.listAgentRuns(task.id)
|
|
2403
|
+
.find((run) => run.status === "active"
|
|
2404
|
+
&& run.purpose === "execution"
|
|
2405
|
+
&& run.executionGroupId === executionGroupId
|
|
2406
|
+
&& run.executionLaneId === executionLaneId
|
|
2407
|
+
&& run.workItemId !== undefined);
|
|
2408
|
+
if (activeLaneRun?.workItemId !== undefined) {
|
|
2409
|
+
return { purpose: "execution", workItemId: activeLaneRun.workItemId, reviewRoundId: "" };
|
|
2410
|
+
}
|
|
1935
2411
|
for (const item of store.listWorkItems(task.id)) {
|
|
1936
2412
|
if (workItemExecutionGroupById(item, executionGroupId)?.lanes.some(({ id }) => id === executionLaneId)) {
|
|
1937
2413
|
return { purpose: "execution", workItemId: item.id, reviewRoundId: "" };
|
|
@@ -1993,6 +2469,76 @@ function retireWorkspaceBoundSession(store, taskId, roleName, now) {
|
|
|
1993
2469
|
store.saveTaskRoleSessionSet(retireTaskRoleSessionsForWorkspace(sessions, now));
|
|
1994
2470
|
}
|
|
1995
2471
|
}
|
|
2472
|
+
function canCorrectActiveWorkItemRoleWorkspaceHint(store, taskId, role, item, workspace) {
|
|
2473
|
+
if (role.taskId !== taskId
|
|
2474
|
+
|| role.status !== "running"
|
|
2475
|
+
|| item.taskId !== taskId
|
|
2476
|
+
|| item.assignee !== role.name
|
|
2477
|
+
|| ["completed", "failed", "retired"].includes(item.status)
|
|
2478
|
+
|| workspace.owner.type !== "work-item"
|
|
2479
|
+
|| workspace.owner.taskId !== taskId
|
|
2480
|
+
|| workspace.owner.workItemId !== item.id)
|
|
2481
|
+
return false;
|
|
2482
|
+
const writableProjects = workspace.entries
|
|
2483
|
+
.filter(({ access }) => access === "write")
|
|
2484
|
+
.map(({ projectId }) => projectId)
|
|
2485
|
+
.sort();
|
|
2486
|
+
if (!isDeepStrictEqual(writableProjects, [...item.writeProjectIds].sort()))
|
|
2487
|
+
return false;
|
|
2488
|
+
const run = store.getActiveAgentRun(taskId, role.name);
|
|
2489
|
+
if (run === null
|
|
2490
|
+
|| run.status !== "active"
|
|
2491
|
+
|| run.purpose !== "execution"
|
|
2492
|
+
|| run.workItemId !== item.id
|
|
2493
|
+
|| run.workspace === undefined
|
|
2494
|
+
|| !sameManagedWorkspaceIdentity(run.workspace, workspace)
|
|
2495
|
+
|| run.effective.agentId !== role.activeAgentId
|
|
2496
|
+
|| !sameEffectiveWorkspace(run.effective.workspace, workspace))
|
|
2497
|
+
return false;
|
|
2498
|
+
const sessions = store.getTaskRoleSessionSet(taskId, role.name);
|
|
2499
|
+
const session = sessions?.sessions[sessions.activeAgentId];
|
|
2500
|
+
if (sessions === null
|
|
2501
|
+
|| sessions.owner.scope !== "task"
|
|
2502
|
+
|| sessions.owner.taskId !== taskId
|
|
2503
|
+
|| sessions.owner.roleName !== role.name
|
|
2504
|
+
|| sessions.activeAgentId !== role.activeAgentId
|
|
2505
|
+
|| sessions.inFlight === null
|
|
2506
|
+
|| sessions.inFlight.agentId !== role.activeAgentId
|
|
2507
|
+
|| sessions.inFlight.runId !== run.id
|
|
2508
|
+
|| sessions.inFlight.receiptId !== formatAgentRunReceiptId(taskId, run.id)
|
|
2509
|
+
|| session === undefined
|
|
2510
|
+
|| session.agentId !== role.activeAgentId
|
|
2511
|
+
|| session.adapterId !== run.effective.adapterId
|
|
2512
|
+
|| session.launchId === undefined
|
|
2513
|
+
|| session.nativeSessionId === undefined
|
|
2514
|
+
|| !["ready", "running"].includes(session.status)
|
|
2515
|
+
|| !isDeepStrictEqual(session.effective, run.effective)
|
|
2516
|
+
|| !sameEffectiveWorkspace(session.effective.workspace, workspace))
|
|
2517
|
+
return false;
|
|
2518
|
+
const lifecycleMailbox = store.getWorkMailbox(runtimeLifecycleTarget({
|
|
2519
|
+
scope: "task",
|
|
2520
|
+
taskId,
|
|
2521
|
+
roleName: role.name
|
|
2522
|
+
}));
|
|
2523
|
+
if (hasRuntimeCleanupObligation(lifecycleMailbox))
|
|
2524
|
+
return false;
|
|
2525
|
+
const lifecycle = lifecycleMailbox?.processing;
|
|
2526
|
+
if (lifecycle !== null
|
|
2527
|
+
&& lifecycle !== undefined
|
|
2528
|
+
&& isRuntimeLaunchReservation(lifecycle)) {
|
|
2529
|
+
const executionRef = lifecycle.executionRef;
|
|
2530
|
+
if (!isRuntimeLaunchReservation(lifecycle, session.launchId)
|
|
2531
|
+
|| executionRef?.type !== "run"
|
|
2532
|
+
|| executionRef.taskId !== taskId
|
|
2533
|
+
|| executionRef.id !== run.id)
|
|
2534
|
+
return false;
|
|
2535
|
+
}
|
|
2536
|
+
return true;
|
|
2537
|
+
}
|
|
2538
|
+
function sameEffectiveWorkspace(effective, workspace) {
|
|
2539
|
+
return effective.root === workspace.root
|
|
2540
|
+
&& isDeepStrictEqual(effective.entries, workspace.entries);
|
|
2541
|
+
}
|
|
1996
2542
|
function sameManagedWorkspace(left, right) {
|
|
1997
2543
|
return isDeepStrictEqual(left, right);
|
|
1998
2544
|
}
|
|
@@ -2003,6 +2549,34 @@ function isMissingPath(error) {
|
|
|
2003
2549
|
return typeof error === "object" && error !== null && "code" in error
|
|
2004
2550
|
&& error.code === "ENOENT";
|
|
2005
2551
|
}
|
|
2552
|
+
/**
|
|
2553
|
+
* The token-bearing ref segments (`task-N-<8hex>`) under a Project's worktree
|
|
2554
|
+
* container for one Task. A missing container yields no segments; legacy
|
|
2555
|
+
* (`task-N`) and foreign-Task directories are ignored.
|
|
2556
|
+
*/
|
|
2557
|
+
async function listTaskTokenSegments(container, taskId) {
|
|
2558
|
+
let entries;
|
|
2559
|
+
try {
|
|
2560
|
+
entries = await readdir(container, { withFileTypes: true });
|
|
2561
|
+
}
|
|
2562
|
+
catch (error) {
|
|
2563
|
+
if (isMissingPath(error))
|
|
2564
|
+
return [];
|
|
2565
|
+
throw error;
|
|
2566
|
+
}
|
|
2567
|
+
const prefix = `${taskId}-`;
|
|
2568
|
+
const segments = [];
|
|
2569
|
+
for (const entry of entries) {
|
|
2570
|
+
if (!entry.isDirectory())
|
|
2571
|
+
continue;
|
|
2572
|
+
if (!entry.name.startsWith(prefix))
|
|
2573
|
+
continue;
|
|
2574
|
+
if (!TASK_WORKSPACE_TOKEN_PATTERN.test(entry.name.slice(prefix.length)))
|
|
2575
|
+
continue;
|
|
2576
|
+
segments.push(entry.name);
|
|
2577
|
+
}
|
|
2578
|
+
return segments.sort();
|
|
2579
|
+
}
|
|
2006
2580
|
function recordWorkspaceDisposition(store, taskId, workItem, now) {
|
|
2007
2581
|
const item = store.getWorkItem(taskId, workItem.workItemId);
|
|
2008
2582
|
if (item === null)
|