@zq-silk/yui 0.15.9 → 0.15.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +8 -4
- package/ARCHITECTURE.zh-CN.md +5 -2
- package/README.md +13 -5
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/agentRun/agentRun.js +3 -0
- package/dist/cli/commandCatalog.js +64 -16
- package/dist/cli/interactionPolicy.js +7 -3
- package/dist/cli/managedDiagnostics.js +1 -1
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +381 -107
- package/dist/commands/executionAuditCommands.js +10 -0
- package/dist/commands/globalRoleCommands.js +339 -4
- package/dist/commands/projectCommands.js +50 -22
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +586 -96
- package/dist/commands/taskIntegrationCommands.js +19 -39
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskOverviewCommand.js +4 -3
- package/dist/commands/taskPublicationAdoptCommand.js +127 -0
- package/dist/commands/taskPublicationCommands.js +11 -2
- package/dist/commands/taskPublicationVerifyCommand.js +23 -39
- package/dist/commands/taskRemoteDeliveryCommand.js +22 -11
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/runContextPack.js +3 -0
- package/dist/context/taskCatalog.js +187 -0
- package/dist/context/taskContext.js +55 -6
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +14 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +519 -25
- package/dist/controller/globalInputDelivery.js +132 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/providerRetryAdmission.js +100 -0
- package/dist/controller/providerRetryDelivery.js +218 -0
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +117 -7
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +329 -386
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/lifecycle/exactRunTerminalization.js +4 -1
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/globalProviderRetry.js +15 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/executionAudit.js +19 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +430 -107
- package/dist/repository/projectMaintenanceLock.js +75 -18
- package/dist/repository/taskWorkspaceCoordinator.js +182 -101
- package/dist/repository/taskWorkspacePreparer.js +205 -72
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/repository/workspaceCleanupInspection.js +187 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentError.js +5 -3
- package/dist/runtime/agentHost.js +179 -82
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/builtinAgentErrorMappers.js +91 -0
- package/dist/runtime/codexAppServerRuntime.js +34 -3
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/providerControl.js +5 -1
- package/dist/runtime/providerRetry.js +198 -0
- package/dist/runtime/providerRuntimeIdentity.js +28 -2
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/sessionTokenMetrics.js +15 -5
- package/dist/runtime/structuredProviderHost.js +6 -2
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/taskUsageMetrics.js +275 -0
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/activeRoleRunDelivery.js +12 -0
- package/dist/scheduler/leaderWakeupProcessor.js +5 -0
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +38 -6
- package/dist/scheduler/taskObservabilityProjection.js +6 -44
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +167 -4
- package/dist/storage/sqliteStore.js +57 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -0
- package/dist/storage/taskCatalog.js +123 -0
- package/dist/storage/taskStore.js +2 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +129 -0
- package/dist/task/archivePreflight.js +124 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/publicationAdoption.js +56 -0
- package/dist/task/publicationReference.js +10 -0
- package/dist/task/remoteDelivery.js +31 -16
- package/dist/web/assets/client/app.js +147 -17
- package/dist/web/assets/client/components.js +56 -13
- package/dist/web/assets/client/i18n.js +78 -4
- package/dist/web/assets/client/taskSurface.js +108 -1
- package/dist/web/assets/client/view.js +39 -8
- package/dist/web/assets/shell.js +29 -0
- package/dist/web/assets/styles/layout.js +8 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +131 -4
- package/dist/web/webSnapshot.js +16 -6
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/cleanupInspection.js +63 -0
- package/dist/workspace/workItemChangeSetManager.js +111 -35
- package/docs/agent-result-consumption.md +4 -0
- package/docs/agent-result-consumption.zh-CN.md +3 -0
- package/docs/agent-runtime-drivers.md +7 -0
- package/docs/agent-runtime-drivers.zh-CN.md +5 -0
- package/docs/architecture/README.md +2 -0
- package/docs/architecture/README.zh-CN.md +3 -1
- package/docs/architecture/capabilities-and-resources.md +30 -5
- package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
- package/docs/managed-turn-and-session-runtime.md +47 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
- package/docs/observability/README.md +62 -0
- package/docs/observability/README.zh-CN.md +47 -0
- package/docs/project-refresh.md +77 -0
- package/docs/project-refresh.zh-CN.md +59 -0
- package/docs/provider-retry.md +70 -0
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/task-delivery.md +133 -13
- package/docs/task-delivery.zh-CN.md +99 -10
- package/docs/task-discovery.md +102 -0
- package/docs/task-discovery.zh-CN.md +86 -0
- package/docs/testing/verification-levels.md +40 -0
- package/docs/testing/verification-levels.zh-CN.md +23 -0
- package/i18n/README.zh-CN.md +13 -7
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +154 -51
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +19 -3
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-runtime/SKILL.md +42 -0
- package/skills/yui-runtime/references/publication.md +42 -0
- package/skills/yui-runtime/references/recovery.md +24 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstat, mkdir, readlink, readdir, rm, rmdir, symlink, unlink } from "node:fs/promises";
|
|
2
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { recordArchiveCleanup } from "../task/archiveDiagnostics.js";
|
|
4
5
|
import { retireTaskRoleSessionsForWorkspace, updateRoleAgentSessionStatus } from "../executor/agentExecutor.js";
|
|
5
6
|
import { formatWorkspacePreflightError } from "../executor/workspacePreflightClassification.js";
|
|
6
7
|
import { updateRole } from "../role/role.js";
|
|
@@ -18,10 +19,13 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
|
|
|
18
19
|
import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
|
|
19
20
|
import { createManagedWorkspace, isTaskOwnedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
|
|
20
21
|
import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
|
|
21
|
-
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
22
|
+
import { acquireProjectMaintenanceLocks, ProjectMaintenanceLockedError, ProjectMaintenanceLockCancelledError } from "./projectMaintenanceLock.js";
|
|
22
23
|
import { generateTaskWorkspaceIdentity, taskWorkspaceRefSegment, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
|
|
23
24
|
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
24
25
|
import { captureTaskBaseProvenance, recordTaskBaseProvenanceEvents } from "./taskBaseFreshness.js";
|
|
26
|
+
import { managedTaskRoot } from "../storage/homeLayout.js";
|
|
27
|
+
import { inspectWorkspaceCleanup } from "./workspaceCleanupInspection.js";
|
|
28
|
+
import { CleanupInspectionError } from "../workspace/cleanupInspection.js";
|
|
25
29
|
const MAIN_WORKTREE = "main";
|
|
26
30
|
const LEADER_ROLE = "leader";
|
|
27
31
|
/**
|
|
@@ -60,11 +64,21 @@ export class FileTaskWorkspacePreparer {
|
|
|
60
64
|
store;
|
|
61
65
|
git;
|
|
62
66
|
now;
|
|
63
|
-
|
|
67
|
+
maintenanceLockOptions;
|
|
68
|
+
constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), maintenanceLockOptions = {}) {
|
|
64
69
|
this.home = home;
|
|
65
70
|
this.store = store;
|
|
66
71
|
this.git = git;
|
|
67
72
|
this.now = now;
|
|
73
|
+
this.maintenanceLockOptions = maintenanceLockOptions;
|
|
74
|
+
}
|
|
75
|
+
inspectWorkspaceCleanup(workspace, disposition, forceArchive = false) {
|
|
76
|
+
return inspectWorkspaceCleanup(this.store, this.git, workspace, disposition, forceArchive);
|
|
77
|
+
}
|
|
78
|
+
async #assertWorkspaceCleanup(workspace, disposition) {
|
|
79
|
+
const checks = await this.inspectWorkspaceCleanup(workspace, disposition);
|
|
80
|
+
if (checks.length > 0)
|
|
81
|
+
throw new CleanupInspectionError(checks);
|
|
68
82
|
}
|
|
69
83
|
#resourceRegistrarValue;
|
|
70
84
|
#resourceRegistrar() {
|
|
@@ -73,7 +87,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
73
87
|
#registerWorkspace(workspace) {
|
|
74
88
|
this.#resourceRegistrar().registerManagedWorkspace(workspace);
|
|
75
89
|
}
|
|
76
|
-
async prepareTaskWorkspace(taskId) {
|
|
90
|
+
async prepareTaskWorkspace(taskId, signal) {
|
|
77
91
|
// The per-Project maintenance fence makes prepare mutually exclusive with
|
|
78
92
|
// Project maintenance and Task archive cleanup: a concurrent migration must not switch the
|
|
79
93
|
// Project catalog to the Home-managed repo while prepare is creating
|
|
@@ -85,7 +99,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
85
99
|
for (let attempt = 0;; attempt += 1) {
|
|
86
100
|
try {
|
|
87
101
|
const task = requireTask(this.store, taskId);
|
|
88
|
-
const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
|
|
102
|
+
const { release, current } = await this.#acquireTaskProjectMaintenanceLocks(task, signal);
|
|
89
103
|
try {
|
|
90
104
|
return await this.#prepareTaskWorkspaceLocked(current.id, false);
|
|
91
105
|
}
|
|
@@ -114,11 +128,14 @@ export class FileTaskWorkspacePreparer {
|
|
|
114
128
|
* later launch failure can never present itself as "activation never
|
|
115
129
|
* happened".
|
|
116
130
|
*/
|
|
117
|
-
async activateTaskWorkspace(taskId, environment = {}) {
|
|
131
|
+
async activateTaskWorkspace(taskId, environment = {}, signal) {
|
|
118
132
|
for (let attempt = 0;; attempt += 1) {
|
|
119
133
|
let attemptedRequest;
|
|
120
134
|
try {
|
|
121
135
|
const task = requireTask(this.store, taskId);
|
|
136
|
+
if (signal?.aborted) {
|
|
137
|
+
throw new ProjectMaintenanceLockCancelledError(task.projectBindings[0]?.projectId ?? task.id, signal.reason);
|
|
138
|
+
}
|
|
122
139
|
const caller = taskLocalActor(this.store, environment, task.id);
|
|
123
140
|
const actor = task.activationRequest?.operation.actorId === `task:${task.id}/role:leader`
|
|
124
141
|
? "leader" : caller;
|
|
@@ -146,12 +163,14 @@ export class FileTaskWorkspacePreparer {
|
|
|
146
163
|
if (admission.disposition === "ready")
|
|
147
164
|
attemptedRequest = admission.request;
|
|
148
165
|
validateDraftTaskForActivation(this.store, task);
|
|
149
|
-
|
|
150
|
-
return adopted.workspaceFree
|
|
151
|
-
? this.#activateWorkspaceFreeTask(task.id, adopted, actor)
|
|
152
|
-
: await this.#prepareActivatedTaskWorkspace(task, adopted, actor);
|
|
166
|
+
return await this.#prepareActivatedTaskWorkspace(task, actor, environment, signal);
|
|
153
167
|
}
|
|
154
168
|
catch (error) {
|
|
169
|
+
// Waiting did not execute the workspace operation. Preserve the
|
|
170
|
+
// activation request instead of turning contention/stop into failure.
|
|
171
|
+
if (error instanceof ProjectMaintenanceLockedError
|
|
172
|
+
|| error instanceof ProjectMaintenanceLockCancelledError)
|
|
173
|
+
throw error;
|
|
155
174
|
if (error instanceof StorageConflictError
|
|
156
175
|
&& attempt < TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
|
|
157
176
|
continue;
|
|
@@ -168,10 +187,28 @@ export class FileTaskWorkspacePreparer {
|
|
|
168
187
|
}
|
|
169
188
|
}
|
|
170
189
|
/** Prepares the managed workspace of a Task being activated, under its fences. */
|
|
171
|
-
async #prepareActivatedTaskWorkspace(task,
|
|
172
|
-
const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
|
|
190
|
+
async #prepareActivatedTaskWorkspace(task, actor, callerEnvironment, signal) {
|
|
191
|
+
const { release, current } = await this.#acquireTaskProjectMaintenanceLocks(task, signal);
|
|
173
192
|
try {
|
|
174
|
-
|
|
193
|
+
taskLocalActor(this.store, callerEnvironment, current.id);
|
|
194
|
+
if (current.executionGate.state !== "enabled") {
|
|
195
|
+
throw new Error(`Task execution is not enabled: ${current.id}.`);
|
|
196
|
+
}
|
|
197
|
+
if (!isDeepStrictEqual(current.activationRequest, task.activationRequest)) {
|
|
198
|
+
throw new Error(`Task activation request changed while waiting for Project maintenance: ${task.id}.`);
|
|
199
|
+
}
|
|
200
|
+
const provider = this.store.getTaskRoleSessionSet(current.id, LEADER_ROLE)?.providerBinding;
|
|
201
|
+
if (provider?.run != null
|
|
202
|
+
&& ["submitting", "accepted", "delivery-unknown"].includes(provider.run.status)) {
|
|
203
|
+
throw new Error("Planning native input is unsettled; preserve activation intent and retry after its exact terminal.");
|
|
204
|
+
}
|
|
205
|
+
validateDraftTaskForActivation(this.store, current);
|
|
206
|
+
// Acquire before resource adoption: a timed-out/cancelled waiter must
|
|
207
|
+
// not leave an adopted environment to be repeated on the next attempt.
|
|
208
|
+
const adopted = await this.#adoptActivationEnvironment(current);
|
|
209
|
+
return adopted.workspaceFree
|
|
210
|
+
? this.#activateWorkspaceFreeTask(current.id, adopted, actor)
|
|
211
|
+
: await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
|
|
175
212
|
}
|
|
176
213
|
finally {
|
|
177
214
|
release();
|
|
@@ -328,12 +365,19 @@ export class FileTaskWorkspacePreparer {
|
|
|
328
365
|
* caller re-reads the Task and re-acquires the correct fence set. The
|
|
329
366
|
* caller owns `release` and MUST call it on every exit path.
|
|
330
367
|
*/
|
|
331
|
-
#acquireTaskProjectMaintenanceLocks(task) {
|
|
368
|
+
async #acquireTaskProjectMaintenanceLocks(task, signal) {
|
|
332
369
|
const projectIds = task.projectBindings.map(({ projectId }) => projectId);
|
|
370
|
+
const waitSignal = signal ?? this.maintenanceLockOptions.signal;
|
|
333
371
|
const release = projectIds.length === 0
|
|
334
372
|
? () => { }
|
|
335
|
-
: acquireProjectMaintenanceLocks(this.home, projectIds
|
|
373
|
+
: await acquireProjectMaintenanceLocks(this.home, projectIds, {
|
|
374
|
+
...this.maintenanceLockOptions,
|
|
375
|
+
signal: waitSignal
|
|
376
|
+
});
|
|
336
377
|
try {
|
|
378
|
+
if (waitSignal?.aborted) {
|
|
379
|
+
throw new ProjectMaintenanceLockCancelledError(projectIds[0] ?? task.id, waitSignal.reason);
|
|
380
|
+
}
|
|
337
381
|
const current = requireTask(this.store, task.id);
|
|
338
382
|
const currentIds = current.projectBindings.map(({ projectId }) => projectId).sort();
|
|
339
383
|
const lockedIds = [...projectIds].sort();
|
|
@@ -356,13 +400,16 @@ export class FileTaskWorkspacePreparer {
|
|
|
356
400
|
* catalog in that gap. The caller owns `release` and must call it on every
|
|
357
401
|
* exit path.
|
|
358
402
|
*/
|
|
359
|
-
acquireTaskProjectMaintenanceLocks(taskId) {
|
|
360
|
-
return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId));
|
|
403
|
+
acquireTaskProjectMaintenanceLocks(taskId, signal) {
|
|
404
|
+
return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId), signal);
|
|
361
405
|
}
|
|
362
406
|
async #prepareTaskWorkspaceLocked(taskId, activate,
|
|
363
407
|
/** Environment and request identity the activation adopted, when one exists. */
|
|
364
408
|
activationEnvironment = { workspaceFree: false }, actor = "user") {
|
|
365
409
|
const task = requireTask(this.store, taskId);
|
|
410
|
+
if (task.executionGate.state !== "enabled") {
|
|
411
|
+
throw new Error(`Task execution is not enabled: ${task.id}.`);
|
|
412
|
+
}
|
|
366
413
|
if (activate && task.status !== "draft") {
|
|
367
414
|
throw new Error(`Only a Draft Task can atomically adopt a workspace: ${task.id}/${task.status}.`);
|
|
368
415
|
}
|
|
@@ -537,7 +584,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
537
584
|
throw new Error(`Task Project baseline was not resolved: ${project.id}.`);
|
|
538
585
|
}
|
|
539
586
|
const identity = worktreeIdentity(taskSegment, MAIN_WORKTREE);
|
|
540
|
-
const destination = join(
|
|
587
|
+
const destination = join(root, safePathSegment(binding.directory));
|
|
541
588
|
const physical = previous === undefined
|
|
542
589
|
? await this.git.clone({
|
|
543
590
|
remoteUrl: project.remoteUrl,
|
|
@@ -908,15 +955,17 @@ export class FileTaskWorkspacePreparer {
|
|
|
908
955
|
return projects;
|
|
909
956
|
}
|
|
910
957
|
async prepareWorkItemWorkspace(taskId, workItemId) {
|
|
911
|
-
|
|
958
|
+
let item = requireWorkItem(this.store, taskId, workItemId);
|
|
912
959
|
const task = requireTask(this.store, item.taskId);
|
|
913
960
|
assertWorkItemWorkspaceEligible(this.store, task, item);
|
|
914
961
|
// Hold the per-Project maintenance fence across the ensure-main-prepared
|
|
915
962
|
// and the WorkItem worktree creation, so concurrent Project maintenance
|
|
916
963
|
// cannot switch the Project catalog between the two. The under-lock Task
|
|
917
964
|
// snapshot drives every Project read and Git effect below.
|
|
918
|
-
const { release, current: lockedTask } = this.#acquireTaskProjectMaintenanceLocks(task);
|
|
965
|
+
const { release, current: lockedTask } = await this.#acquireTaskProjectMaintenanceLocks(task);
|
|
919
966
|
try {
|
|
967
|
+
item = requireWorkItem(this.store, taskId, workItemId);
|
|
968
|
+
assertWorkItemWorkspaceEligible(this.store, lockedTask, item);
|
|
920
969
|
const lockedProjectIds = lockedTask.projectBindings.map(({ projectId }) => projectId);
|
|
921
970
|
await this.#prepareTaskWorkspaceWithRetries(lockedTask.id, lockedProjectIds);
|
|
922
971
|
// prepareTaskWorkspace may have just minted and persisted the workspace
|
|
@@ -966,9 +1015,25 @@ export class FileTaskWorkspacePreparer {
|
|
|
966
1015
|
const requestedBase = previous?.access === "write" || requestedBaseRef === undefined
|
|
967
1016
|
? null
|
|
968
1017
|
: await this.git.inspect(mainEntry.path, requestedBaseRef);
|
|
1018
|
+
if (previous?.access === "read" && previous.directory === binding.directory) {
|
|
1019
|
+
const viewPath = join(root, binding.directory);
|
|
1020
|
+
const metadata = await lstat(viewPath).catch((error) => {
|
|
1021
|
+
if (errorCode(error) === "ENOENT")
|
|
1022
|
+
return null;
|
|
1023
|
+
throw error;
|
|
1024
|
+
});
|
|
1025
|
+
if (metadata?.isSymbolicLink()) {
|
|
1026
|
+
if (resolve(root, await readlink(viewPath)) !== resolve(previous.path)) {
|
|
1027
|
+
throw new Error(`Read-only workspace view changed before scope expansion: ${viewPath}.`);
|
|
1028
|
+
}
|
|
1029
|
+
// Remove only the registered read view, never its target.
|
|
1030
|
+
await unlink(viewPath);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
969
1033
|
const physical = await this.git.ensureWorktree({
|
|
970
1034
|
repositoryPath: mainEntry.path,
|
|
971
|
-
container:
|
|
1035
|
+
container: root,
|
|
1036
|
+
directory: binding.directory,
|
|
972
1037
|
taskSegment,
|
|
973
1038
|
roleName: item.id,
|
|
974
1039
|
baseRef
|
|
@@ -1081,9 +1146,12 @@ export class FileTaskWorkspacePreparer {
|
|
|
1081
1146
|
// already holds (one locked boundary across preparation and adoption);
|
|
1082
1147
|
// otherwise the fence is acquired here.
|
|
1083
1148
|
const { release, current: lockedTask } = heldFence === undefined
|
|
1084
|
-
? this.#acquireTaskProjectMaintenanceLocks(task)
|
|
1149
|
+
? await this.#acquireTaskProjectMaintenanceLocks(task)
|
|
1085
1150
|
: { release: () => { }, current: heldFence.current };
|
|
1086
1151
|
try {
|
|
1152
|
+
if (lockedTask.status !== "active" || lockedTask.executionGate.state !== "enabled") {
|
|
1153
|
+
throw new Error(`Task is not open for Execution Lane preparation: ${lockedTask.id}.`);
|
|
1154
|
+
}
|
|
1087
1155
|
const lineage = executionLaneLineage(this.store, lockedTask, executionGroupId, executionLaneId, hint);
|
|
1088
1156
|
const item = lineage.purpose === "execution"
|
|
1089
1157
|
? this.store.getWorkItem(taskId, lineage.workItemId)
|
|
@@ -1139,7 +1207,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
1139
1207
|
const project = requireProject(this.store, entry.projectId);
|
|
1140
1208
|
const physical = await this.git.ensureWorktree({
|
|
1141
1209
|
repositoryPath: this.#taskRepositoryPath(taskId, project.id),
|
|
1142
|
-
container:
|
|
1210
|
+
container: existing.root,
|
|
1211
|
+
directory: entry.directory,
|
|
1143
1212
|
taskSegment,
|
|
1144
1213
|
roleName: managedWorktreeName(owner),
|
|
1145
1214
|
baseRef: entry.baseCommit
|
|
@@ -1151,6 +1220,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1151
1220
|
return existing;
|
|
1152
1221
|
}
|
|
1153
1222
|
const prepared = [];
|
|
1223
|
+
const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
|
|
1154
1224
|
try {
|
|
1155
1225
|
for (const binding of lockedTask.projectBindings) {
|
|
1156
1226
|
const project = requireProject(this.store, binding.projectId);
|
|
@@ -1162,7 +1232,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
1162
1232
|
const inputHead = inputHeadByProject.get(project.id) ?? sourceEntry.baseCommit;
|
|
1163
1233
|
const physical = await this.git.ensureWorktree({
|
|
1164
1234
|
repositoryPath: this.#taskRepositoryPath(taskId, project.id),
|
|
1165
|
-
container:
|
|
1235
|
+
container: root,
|
|
1236
|
+
directory: sourceEntry.directory,
|
|
1166
1237
|
taskSegment,
|
|
1167
1238
|
roleName: managedWorktreeName(owner),
|
|
1168
1239
|
baseRef: inputHead
|
|
@@ -1182,7 +1253,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
1182
1253
|
}
|
|
1183
1254
|
});
|
|
1184
1255
|
}
|
|
1185
|
-
const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
|
|
1186
1256
|
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
1187
1257
|
const workspace = createManagedWorkspace({
|
|
1188
1258
|
owner,
|
|
@@ -1249,6 +1319,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1249
1319
|
&& owner.executionLaneId === executionLaneId));
|
|
1250
1320
|
if (workspace === undefined)
|
|
1251
1321
|
return "missing";
|
|
1322
|
+
await this.#assertWorkspaceCleanup(workspace, "abandoned");
|
|
1252
1323
|
const state = await this.#inspectEntries(task.id, this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
1253
1324
|
if (state === "dirty")
|
|
1254
1325
|
return "dirty";
|
|
@@ -1257,13 +1328,15 @@ export class FileTaskWorkspacePreparer {
|
|
|
1257
1328
|
const project = requireProject(this.store, entry.projectId);
|
|
1258
1329
|
const result = await this.git.removeWorktree({
|
|
1259
1330
|
repositoryPath: this.#taskRepositoryPath(task.id, project.id),
|
|
1260
|
-
container:
|
|
1331
|
+
container: dirname(entry.path),
|
|
1332
|
+
directory: entry.directory,
|
|
1261
1333
|
taskSegment: this.#taskSegment(task),
|
|
1262
1334
|
roleName: managedWorktreeName(workspace.owner),
|
|
1263
1335
|
deleteBranch: true
|
|
1264
1336
|
});
|
|
1265
1337
|
if (result === "dirty")
|
|
1266
1338
|
return "dirty";
|
|
1339
|
+
this.#recordArchivePathRemoval(task, entry.path, result);
|
|
1267
1340
|
removed ||= result === "removed";
|
|
1268
1341
|
}
|
|
1269
1342
|
await removeWorkspaceView(workspace.root);
|
|
@@ -1310,7 +1383,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
1310
1383
|
const project = requireProject(this.store, entry.projectId);
|
|
1311
1384
|
const result = await this.git.removeWorktree({
|
|
1312
1385
|
repositoryPath: this.#taskRepositoryPath(task.id, project.id),
|
|
1313
|
-
container:
|
|
1386
|
+
container: dirname(entry.path),
|
|
1387
|
+
directory: entry.directory,
|
|
1314
1388
|
taskSegment,
|
|
1315
1389
|
roleName: managedWorktreeName(workspace.owner),
|
|
1316
1390
|
deleteBranch: true
|
|
@@ -1381,8 +1455,11 @@ export class FileTaskWorkspacePreparer {
|
|
|
1381
1455
|
* never used as a ChangeSet capture source. */
|
|
1382
1456
|
async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
1383
1457
|
const taskSnapshot = requireTask(this.store, taskId);
|
|
1384
|
-
const { release, current: task } = this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
|
|
1458
|
+
const { release, current: task } = await this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
|
|
1385
1459
|
try {
|
|
1460
|
+
if (task.status !== "active" || task.executionGate.state !== "enabled") {
|
|
1461
|
+
throw new Error(`Task is not open for ReviewRound preparation: ${task.id}.`);
|
|
1462
|
+
}
|
|
1386
1463
|
const taskSegment = this.#taskSegment(task);
|
|
1387
1464
|
const round = this.store.getReviewRound(task.id, reviewRoundId);
|
|
1388
1465
|
if (round === null)
|
|
@@ -1417,19 +1494,19 @@ export class FileTaskWorkspacePreparer {
|
|
|
1417
1494
|
const snapshotCommits = new Map((taskScope
|
|
1418
1495
|
? round.taskCandidate?.projects ?? []
|
|
1419
1496
|
: candidate.gitSnapshot.projects).map(({ projectId, commit }) => [projectId, commit]));
|
|
1497
|
+
const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round);
|
|
1420
1498
|
const frozenEntries = taskScope
|
|
1421
1499
|
? task.projectBindings.map((binding) => {
|
|
1422
1500
|
const commit = snapshotCommits.get(binding.projectId);
|
|
1423
1501
|
if (commit === undefined) {
|
|
1424
1502
|
throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
|
|
1425
1503
|
}
|
|
1426
|
-
const project = requireProject(this.store, binding.projectId);
|
|
1427
1504
|
const identity = worktreeIdentity(taskSegment, this.#reviewWorktreeName(round));
|
|
1428
1505
|
return {
|
|
1429
1506
|
projectId: binding.projectId,
|
|
1430
1507
|
directory: binding.directory,
|
|
1431
1508
|
access: "write",
|
|
1432
|
-
path: join(
|
|
1509
|
+
path: join(reviewRoot, safePathSegment(binding.directory)),
|
|
1433
1510
|
branch: identity.branch,
|
|
1434
1511
|
baseRef: commit,
|
|
1435
1512
|
baseCommit: commit
|
|
@@ -1447,7 +1524,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
1447
1524
|
}
|
|
1448
1525
|
const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
|
|
1449
1526
|
const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
|
|
1450
|
-
const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round);
|
|
1451
1527
|
if (taskScope && existing === null) {
|
|
1452
1528
|
const reassigned = await this.#reassignTaskReviewWorkspace(task, round, reviewer, reviewRoot, frozenEntries);
|
|
1453
1529
|
if (reassigned !== null)
|
|
@@ -1482,7 +1558,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1482
1558
|
}
|
|
1483
1559
|
const project = requireProject(this.store, entry.projectId);
|
|
1484
1560
|
const identity = worktreeIdentity(taskSegment, this.#reviewWorktreeName(round));
|
|
1485
|
-
const expectedPath = join(
|
|
1561
|
+
const expectedPath = join(reviewRoot, safePathSegment(entry.directory));
|
|
1486
1562
|
if (entry.path !== expectedPath || entry.branch !== identity.branch) {
|
|
1487
1563
|
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
|
|
1488
1564
|
}
|
|
@@ -1568,7 +1644,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
1568
1644
|
const project = requireProject(this.store, source.projectId);
|
|
1569
1645
|
const physical = await this.git.ensureWorktree({
|
|
1570
1646
|
repositoryPath: this.#taskRepositoryPath(task.id, project.id),
|
|
1571
|
-
container:
|
|
1647
|
+
container: reviewRoot,
|
|
1648
|
+
directory: source.directory,
|
|
1572
1649
|
taskSegment,
|
|
1573
1650
|
roleName: this.#reviewWorktreeName(round),
|
|
1574
1651
|
baseRef: source.baseCommit
|
|
@@ -1855,6 +1932,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1855
1932
|
if (!isDeepStrictEqual(round.workspace, workspace)) {
|
|
1856
1933
|
throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1857
1934
|
}
|
|
1935
|
+
await this.#assertWorkspaceCleanup(workspace, "abandoned");
|
|
1858
1936
|
assertWorkspaceSessionsRetirable(this.store, task.id, round.reviewerRoleName, this.now());
|
|
1859
1937
|
for (const lane of round.executionGroup?.lanes ?? []) {
|
|
1860
1938
|
assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
|
|
@@ -1869,7 +1947,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
1869
1947
|
const project = requireProject(this.store, entry.projectId);
|
|
1870
1948
|
const result = await this.git.removeWorktree({
|
|
1871
1949
|
repositoryPath: this.#taskRepositoryPath(task.id, project.id),
|
|
1872
|
-
container:
|
|
1950
|
+
container: dirname(entry.path),
|
|
1951
|
+
directory: entry.directory,
|
|
1873
1952
|
taskSegment: this.#taskSegment(task),
|
|
1874
1953
|
roleName: this.#reviewWorktreeName(round),
|
|
1875
1954
|
deleteBranch: true
|
|
@@ -1877,6 +1956,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1877
1956
|
if (result === "dirty") {
|
|
1878
1957
|
throw new Error(`Review workspace changed after cleanup preflight: ${round.id}.`);
|
|
1879
1958
|
}
|
|
1959
|
+
this.#recordArchivePathRemoval(task, entry.path, result);
|
|
1880
1960
|
removed ||= result === "removed";
|
|
1881
1961
|
}
|
|
1882
1962
|
await removeWorkspaceView(workspace.root);
|
|
@@ -1920,6 +2000,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1920
2000
|
throw new Error(`WorkItem has no managed isolated worktree workspace: ${item.id}.`);
|
|
1921
2001
|
}
|
|
1922
2002
|
assertWorkItemOwnsWorkspace(item, workspace);
|
|
2003
|
+
await this.#assertWorkspaceCleanup(workspace, disposition);
|
|
1923
2004
|
if (item.assignee !== undefined) {
|
|
1924
2005
|
assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
|
|
1925
2006
|
}
|
|
@@ -1935,7 +2016,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
1935
2016
|
const project = requireProject(this.store, entry.projectId);
|
|
1936
2017
|
const result = await this.git.removeWorktree({
|
|
1937
2018
|
repositoryPath: this.#taskRepositoryPath(task.id, project.id),
|
|
1938
|
-
container:
|
|
2019
|
+
container: dirname(entry.path),
|
|
2020
|
+
directory: entry.directory,
|
|
1939
2021
|
taskSegment: this.#taskSegment(task),
|
|
1940
2022
|
roleName: managedWorktreeName(workspace.owner),
|
|
1941
2023
|
deleteBranch: true
|
|
@@ -1943,6 +2025,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1943
2025
|
if (result === "dirty") {
|
|
1944
2026
|
throw new Error(`WorkItem workspace changed after cleanup preflight: ${item.id}.`);
|
|
1945
2027
|
}
|
|
2028
|
+
this.#recordArchivePathRemoval(task, entry.path, result);
|
|
1946
2029
|
removed ||= result === "removed";
|
|
1947
2030
|
}
|
|
1948
2031
|
await removeWorkspaceView(workspace.root);
|
|
@@ -1956,7 +2039,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1956
2039
|
catch (error) {
|
|
1957
2040
|
if (!removed)
|
|
1958
2041
|
throw error;
|
|
1959
|
-
throw new Error(`WorkItem worktree was removed but durable cleanup was not recorded; retry cleanup: ${error instanceof Error ? error.message : String(error)}
|
|
2042
|
+
throw new Error(`WorkItem worktree was removed but durable cleanup was not recorded; retry cleanup: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1960
2043
|
}
|
|
1961
2044
|
return removed ? "removed" : "missing";
|
|
1962
2045
|
}
|
|
@@ -1971,7 +2054,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1971
2054
|
let found = false;
|
|
1972
2055
|
for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
|
|
1973
2056
|
try {
|
|
1974
|
-
if (!await this.git.
|
|
2057
|
+
if (!await this.git.inspectClean(entry.path))
|
|
1975
2058
|
return "dirty";
|
|
1976
2059
|
found = true;
|
|
1977
2060
|
}
|
|
@@ -1983,7 +2066,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1983
2066
|
}
|
|
1984
2067
|
return found ? "clean" : "missing";
|
|
1985
2068
|
}
|
|
1986
|
-
async cleanupIntegrationWorkspace(taskId, integrationId) {
|
|
2069
|
+
async cleanupIntegrationWorkspace(taskId, integrationId, options = {}) {
|
|
1987
2070
|
const task = requireTask(this.store, taskId);
|
|
1988
2071
|
const attempt = this.store.getIntegrationAttempt(task.id, integrationId);
|
|
1989
2072
|
if (attempt === null) {
|
|
@@ -1991,19 +2074,41 @@ export class FileTaskWorkspacePreparer {
|
|
|
1991
2074
|
}
|
|
1992
2075
|
if (attempt.status === "running"
|
|
1993
2076
|
|| attempt.status === "blocked"
|
|
2077
|
+
|| attempt.status === "conflicted"
|
|
1994
2078
|
|| attempt.status === "validating") {
|
|
1995
2079
|
throw new Error(`IntegrationAttempt must be terminal before cleanup: ${attempt.id}.`);
|
|
1996
2080
|
}
|
|
1997
2081
|
const workspace = this.store.getIntegrationWorkspace(task.id, attempt.id);
|
|
2082
|
+
// A failed Integration's explicitly disposable conflict tree retains its
|
|
2083
|
+
// existing cleanup contract outside archive. Archive never discards dirt.
|
|
2084
|
+
if (workspace !== null && (options.preserveChanges || task.status === "archived" || attempt.status !== "failed")) {
|
|
2085
|
+
await this.#assertWorkspaceCleanup(workspace, "abandoned");
|
|
2086
|
+
}
|
|
1998
2087
|
const project = requireProject(this.store, attempt.projectId);
|
|
2088
|
+
// The recorded entry pins the exact on-disk worktree path; reconstruct the
|
|
2089
|
+
// owner root from it so removal targets the real Git worktree regardless of
|
|
2090
|
+
// how the layout helper derives it. Absent a recorded workspace, fall back
|
|
2091
|
+
// to the deterministic integration owner root + bound directory.
|
|
2092
|
+
const recorded = workspace?.entries[0];
|
|
2093
|
+
const directory = recorded?.directory
|
|
2094
|
+
?? task.projectBindings.find(({ projectId }) => projectId === attempt.projectId)?.directory;
|
|
2095
|
+
if (directory === undefined) {
|
|
2096
|
+
throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
|
|
2097
|
+
}
|
|
1999
2098
|
const result = await this.git.removeIntegrationWorktree({
|
|
2000
2099
|
repositoryPath: this.#taskRepositoryPath(task.id, project.id),
|
|
2001
|
-
container:
|
|
2100
|
+
container: recorded !== undefined
|
|
2101
|
+
? dirname(recorded.path)
|
|
2102
|
+
: this.#integrationWorkspaceRoot(task.id, attempt.id),
|
|
2103
|
+
directory,
|
|
2002
2104
|
taskSegment: this.#taskSegment(task),
|
|
2003
2105
|
integrationId: attempt.id,
|
|
2004
|
-
discardChanges: attempt.status === "failed"
|
|
2106
|
+
discardChanges: options.preserveChanges !== true && task.status !== "archived" && attempt.status === "failed"
|
|
2005
2107
|
});
|
|
2006
2108
|
if (result !== "dirty") {
|
|
2109
|
+
for (const entry of workspace?.entries ?? []) {
|
|
2110
|
+
this.#recordArchivePathRemoval(task, entry.path, result);
|
|
2111
|
+
}
|
|
2007
2112
|
if (workspace !== null)
|
|
2008
2113
|
this.#resourceRegistrar().markWorkspaceDeleted(workspace);
|
|
2009
2114
|
await rm(join(this.home, "artifacts", "integration-checks", task.id, attempt.id), { recursive: true, force: true });
|
|
@@ -2025,7 +2130,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
2025
2130
|
}
|
|
2026
2131
|
return this.#inspectEntries(task.id, this.#taskSegment(task), MAIN_WORKTREE, main.entries);
|
|
2027
2132
|
}
|
|
2028
|
-
async cleanupTaskForArchive(taskId) {
|
|
2133
|
+
async cleanupTaskForArchive(taskId, disposition = "abandoned") {
|
|
2029
2134
|
const task = requireTask(this.store, taskId);
|
|
2030
2135
|
assertTaskArchiveState(task, task);
|
|
2031
2136
|
const isolated = this.store.listManagedWorkspaces(task.id)
|
|
@@ -2040,6 +2145,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
2040
2145
|
}
|
|
2041
2146
|
const main = this.store.getTaskWorkspace(task.id);
|
|
2042
2147
|
if (main !== null) {
|
|
2148
|
+
await this.#assertWorkspaceCleanup(main, disposition);
|
|
2043
2149
|
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
2044
2150
|
if (await this.#inspectEntries(task.id, this.#taskSegment(task), MAIN_WORKTREE, main.entries) === "dirty") {
|
|
2045
2151
|
return {
|
|
@@ -2050,10 +2156,14 @@ export class FileTaskWorkspacePreparer {
|
|
|
2050
2156
|
}
|
|
2051
2157
|
for (const entry of main.entries) {
|
|
2052
2158
|
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
2053
|
-
const result = await this.git.
|
|
2159
|
+
const result = await this.git.removeTaskClone({
|
|
2160
|
+
path: entry.path, container: main.root, directory: entry.directory,
|
|
2161
|
+
taskSegment: this.#taskSegment(task), branch: entry.branch
|
|
2162
|
+
});
|
|
2054
2163
|
if (result === "dirty") {
|
|
2055
2164
|
throw new Error(`Task workspace changed after cleanup preflight: ${task.id}.`);
|
|
2056
2165
|
}
|
|
2166
|
+
this.#recordArchivePathRemoval(task, entry.path, result);
|
|
2057
2167
|
}
|
|
2058
2168
|
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
2059
2169
|
await removeWorkspaceView(main.root);
|
|
@@ -2064,6 +2174,16 @@ export class FileTaskWorkspacePreparer {
|
|
|
2064
2174
|
this.#clearTaskWorkspace(task, this.#fallbackWorkspace());
|
|
2065
2175
|
return { taskId, status: "removed" };
|
|
2066
2176
|
}
|
|
2177
|
+
#recordArchivePathRemoval(task, path, status) {
|
|
2178
|
+
if (task.status !== "archived")
|
|
2179
|
+
return;
|
|
2180
|
+
recordArchiveCleanup(this.store, task.id, {
|
|
2181
|
+
resource: `path:${path}`, paths: [path],
|
|
2182
|
+
detail: status === "missing"
|
|
2183
|
+
? "Directory absent; exact Git cleanup completed where applicable."
|
|
2184
|
+
: "Exact owned path removed."
|
|
2185
|
+
}, status, this.now());
|
|
2186
|
+
}
|
|
2067
2187
|
async #inspectEntries(taskId, taskSegment, roleName, entries) {
|
|
2068
2188
|
if (entries.length === 0)
|
|
2069
2189
|
return "missing";
|
|
@@ -2072,7 +2192,11 @@ export class FileTaskWorkspacePreparer {
|
|
|
2072
2192
|
const project = requireProject(this.store, entry.projectId);
|
|
2073
2193
|
const state = await this.git.inspectWorktree({
|
|
2074
2194
|
repositoryPath: this.#taskRepositoryPath(taskId, project.id),
|
|
2075
|
-
|
|
2195
|
+
// A managed worktree lives at `root/<directory>`, so its parent is the
|
|
2196
|
+
// owner workspace root. Deriving it from the entry keeps this helper
|
|
2197
|
+
// agnostic to which owner (main/work-item/review/lane) it serves.
|
|
2198
|
+
container: dirname(entry.path),
|
|
2199
|
+
directory: entry.directory,
|
|
2076
2200
|
taskSegment,
|
|
2077
2201
|
roleName
|
|
2078
2202
|
});
|
|
@@ -2082,9 +2206,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
2082
2206
|
}
|
|
2083
2207
|
return found ? "clean" : "missing";
|
|
2084
2208
|
}
|
|
2085
|
-
#projectContainer(projectName) {
|
|
2086
|
-
return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
|
|
2087
|
-
}
|
|
2088
2209
|
/**
|
|
2089
2210
|
* The Task workspace ref segment for Git worktree derivation: the
|
|
2090
2211
|
* token-bearing segment for a Task with a persisted identity, its bare id
|
|
@@ -2095,13 +2216,16 @@ export class FileTaskWorkspacePreparer {
|
|
|
2095
2216
|
return taskWorkspaceRefSegment(task);
|
|
2096
2217
|
}
|
|
2097
2218
|
#taskWorkspaceRoot(taskId) {
|
|
2098
|
-
return join(resolveTaskRoot(this.home
|
|
2219
|
+
return join(resolveTaskRoot(this.home), safePathSegment(taskId), "main");
|
|
2099
2220
|
}
|
|
2100
2221
|
#workItemWorkspaceRoot(taskId, workItemId) {
|
|
2101
|
-
return join(resolveTaskRoot(this.home
|
|
2222
|
+
return join(resolveTaskRoot(this.home), safePathSegment(taskId), "work-items", safePathSegment(workItemId));
|
|
2102
2223
|
}
|
|
2103
2224
|
#reviewRoundWorkspaceRoot(taskId, round) {
|
|
2104
|
-
return join(resolveTaskRoot(this.home
|
|
2225
|
+
return join(resolveTaskRoot(this.home), safePathSegment(taskId), "reviews", safePathSegment(this.#reviewWorktreeName(round)));
|
|
2226
|
+
}
|
|
2227
|
+
#integrationWorkspaceRoot(taskId, integrationId) {
|
|
2228
|
+
return integrationWorkspaceRoot(this.home, taskId, integrationId);
|
|
2105
2229
|
}
|
|
2106
2230
|
#reviewWorktreeName(round) {
|
|
2107
2231
|
return (round.scope ?? "work-item") === "task"
|
|
@@ -2109,7 +2233,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
2109
2233
|
: round.id;
|
|
2110
2234
|
}
|
|
2111
2235
|
#executionLaneWorkspaceRoot(taskId, groupId, laneId) {
|
|
2112
|
-
return join(resolveTaskRoot(this.home
|
|
2236
|
+
return join(resolveTaskRoot(this.home), safePathSegment(taskId), "execution-lanes", safePathSegment(groupId), safePathSegment(laneId));
|
|
2113
2237
|
}
|
|
2114
2238
|
#fallbackWorkspace() {
|
|
2115
2239
|
return this.store.getConfig().defaultWorkspace ?? process.cwd();
|
|
@@ -2127,7 +2251,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
2127
2251
|
}
|
|
2128
2252
|
const removal = await this.git.removeWorktree({
|
|
2129
2253
|
repositoryPath: this.#taskRepositoryPath(task.id, project.id),
|
|
2130
|
-
container:
|
|
2254
|
+
container: dirname(entry.path),
|
|
2255
|
+
directory: entry.directory,
|
|
2131
2256
|
taskSegment,
|
|
2132
2257
|
roleName,
|
|
2133
2258
|
...(deleteBranch ? { deleteBranch: true } : {})
|
|
@@ -2218,7 +2343,7 @@ function requireTask(store, taskId) {
|
|
|
2218
2343
|
return task;
|
|
2219
2344
|
}
|
|
2220
2345
|
function assertTaskArchiveState(current, expected) {
|
|
2221
|
-
if ((current.status !== "completed" && current.status !== "cancelled")
|
|
2346
|
+
if ((current.status !== "completed" && current.status !== "cancelled" && current.status !== "archived")
|
|
2222
2347
|
|| !isDeepStrictEqual(current, expected)) {
|
|
2223
2348
|
throw new WorkspaceCleanupBlockedError("task-changed", `task:${expected.id}`, true, `Task changed during archive cleanup: ${expected.id}.`);
|
|
2224
2349
|
}
|
|
@@ -2441,14 +2566,28 @@ function recordWorkspaceDisposition(store, taskId, workItem, now) {
|
|
|
2441
2566
|
throw new Error(`Work item not found: ${workItem.workItemId}.`);
|
|
2442
2567
|
store.saveWorkItem(taskId, recordWorkItemWorkspaceDisposition(item, workItem.disposition, now));
|
|
2443
2568
|
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Reconcile an owner workspace directory so it presents every bound Project at
|
|
2571
|
+
* `root/<directory>`.
|
|
2572
|
+
*
|
|
2573
|
+
* Writable Projects are real Git worktrees created in place at `root/<directory>`
|
|
2574
|
+
* by the Git port before this runs; they are left untouched here. Read-only
|
|
2575
|
+
* Projects share an upstream worktree (the Task main clone) and are surfaced as
|
|
2576
|
+
* genuine symlinks `root/<directory> -> entry.path`. Only the symlink views are
|
|
2577
|
+
* reconciled: a stale or renamed read link is repaired, an obsolete one removed.
|
|
2578
|
+
* A real directory (a write worktree) is never treated as a view entry, so this
|
|
2579
|
+
* neither creates nor removes committed Git content.
|
|
2580
|
+
*/
|
|
2444
2581
|
async function ensureWorkspaceView(root, entries) {
|
|
2445
2582
|
await mkdir(root, { recursive: true });
|
|
2446
|
-
const expected = new Map(entries.map((entry) => [entry.directory, entry.path]));
|
|
2583
|
+
const expected = new Map(entries.filter((entry) => entry.access === "read").map((entry) => [entry.directory, entry.path]));
|
|
2447
2584
|
for (const current of await readdir(root, { withFileTypes: true })) {
|
|
2585
|
+
// A write entry is a real worktree directory living in place under root; it
|
|
2586
|
+
// is owned by Git, not by this view, so it is never reconciled or removed
|
|
2587
|
+
// here. Only symlinks (read-entry views) are this function's concern.
|
|
2588
|
+
if (!current.isSymbolicLink())
|
|
2589
|
+
continue;
|
|
2448
2590
|
const target = expected.get(current.name);
|
|
2449
|
-
if (!current.isSymbolicLink()) {
|
|
2450
|
-
throw new Error(`Managed workspace contains an unexpected entry: ${join(root, current.name)}.`);
|
|
2451
|
-
}
|
|
2452
2591
|
const path = join(root, current.name);
|
|
2453
2592
|
const linked = resolve(root, await readlink(path));
|
|
2454
2593
|
if (target === undefined) {
|
|
@@ -2493,23 +2632,17 @@ function safePathSegment(value) {
|
|
|
2493
2632
|
function sameCommit(left, right) {
|
|
2494
2633
|
return left.toLowerCase() === right.toLowerCase();
|
|
2495
2634
|
}
|
|
2496
|
-
export function
|
|
2497
|
-
return
|
|
2498
|
-
}
|
|
2499
|
-
export function resolveTaskRoot(home, workspace) {
|
|
2500
|
-
return join(resolveWorkspaceRoot(home, workspace), "tasks");
|
|
2635
|
+
export function resolveTaskRoot(home) {
|
|
2636
|
+
return managedTaskRoot(home);
|
|
2501
2637
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
throw new Error("Project workspace must be outside YUI_HOME.");
|
|
2511
|
-
}
|
|
2512
|
-
return workspaceRoot;
|
|
2638
|
+
/**
|
|
2639
|
+
* The integration owner root: `tasks/<taskId>/integrations/<integrationId>`.
|
|
2640
|
+
* A single-project integration worktree lives at `<root>/<boundDirectory>`.
|
|
2641
|
+
* Shared by the preparer and the integration service so create and cleanup
|
|
2642
|
+
* derive the same on-disk path.
|
|
2643
|
+
*/
|
|
2644
|
+
export function integrationWorkspaceRoot(home, taskId, integrationId) {
|
|
2645
|
+
return join(managedTaskRoot(home), safePathSegment(taskId), "integrations", safePathSegment(integrationId));
|
|
2513
2646
|
}
|
|
2514
2647
|
function errorCode(error) {
|
|
2515
2648
|
return typeof error === "object" && error !== null && "code" in error
|