@zq-silk/yui 0.15.8 → 0.15.11
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 +2 -0
- package/ARCHITECTURE.zh-CN.md +151 -0
- package/README.md +211 -14
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/artifacts/artifactCapability.js +74 -0
- package/dist/artifacts/artifactCommitLock.js +249 -0
- package/dist/artifacts/artifactPaths.js +151 -0
- package/dist/artifacts/gitArtifactRef.js +146 -0
- package/dist/artifacts/managedGit.js +332 -0
- package/dist/artifacts/taskArtifactRepository.js +277 -0
- package/dist/cli/commandCatalog.js +40 -16
- package/dist/cli/interactionPolicy.js +3 -3
- 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 +403 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/operatorCommands.js +33 -2
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActivationCommands.js +22 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +846 -155
- package/dist/commands/taskIntegrationCommands.js +16 -38
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/runContextPack.js +28 -16
- package/dist/context/taskContext.js +64 -5
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +11 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
- package/dist/controller/globalInputDelivery.js +119 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +81 -6
- 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 +312 -382
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/kernel/builtinCapabilities.js +32 -24
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +423 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/plugins/pluginService.js +11 -3
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +72 -15
- package/dist/repository/taskWorkspaceCoordinator.js +134 -0
- package/dist/repository/taskWorkspacePreparer.js +120 -49
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/resources/projectResource.js +0 -48
- package/dist/resources/projectResourceService.js +3 -81
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentHost.js +152 -72
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +12 -1
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +29 -16
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/artifactsToGit.js +338 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/submitIntent.js +126 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +173 -7
- package/dist/storage/sqliteStore.js +41 -22
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -1
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/taskActivation.js +26 -0
- package/dist/task/taskActivationService.js +85 -69
- package/dist/task/taskSubmission.js +236 -0
- package/dist/web/assets/client/app.js +58 -2
- package/dist/web/assets/client/components.js +1 -0
- package/dist/web/assets/client/i18n.js +6 -0
- package/dist/web/assets/client/taskSurface.js +202 -7
- package/dist/web/assets/client/view.js +7 -4
- package/dist/web/assets/shell.js +23 -0
- package/dist/web/assets/styles/layout.js +1 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +135 -4
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +225 -8
- package/dist/workItem/workItem.js +14 -10
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/agent-result-consumption.md +2 -0
- package/docs/agent-result-consumption.zh-CN.md +81 -0
- package/docs/agent-runtime-drivers.md +2 -0
- package/docs/agent-runtime-drivers.zh-CN.md +77 -0
- package/docs/architecture/README.md +44 -32
- package/docs/architecture/README.zh-CN.md +43 -0
- package/docs/architecture/capabilities-and-resources.md +118 -79
- package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
- package/docs/managed-turn-and-session-runtime.md +2 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
- package/docs/observability/README.md +2 -0
- package/docs/observability/README.zh-CN.md +71 -0
- package/docs/plugin-sdk.md +320 -217
- package/docs/plugin-sdk.zh-CN.md +293 -0
- package/docs/provider-runtime.md +2 -0
- package/docs/provider-runtime.zh-CN.md +132 -0
- package/docs/release-workflow.md +41 -0
- package/docs/release-workflow.zh-CN.md +266 -0
- package/docs/roles-and-configuration.md +2 -0
- package/docs/roles-and-configuration.zh-CN.md +96 -0
- package/docs/sqlite-control-plane-design.md +225 -1
- package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
- package/docs/task-dag-semantics.md +80 -57
- package/docs/task-dag-semantics.zh-CN.md +59 -0
- package/docs/task-delivery.md +2 -0
- package/docs/task-delivery.zh-CN.md +82 -0
- package/docs/task-local-identity.md +2 -0
- package/docs/task-local-identity.zh-CN.md +58 -0
- package/docs/testing/verification-levels.md +26 -0
- package/docs/testing/verification-levels.zh-CN.md +80 -0
- package/i18n/README.zh-CN.md +199 -10
- package/package.json +2 -1
- package/skills/yui-leader/SKILL.md +88 -331
- package/skills/yui-leader/references/execution.md +405 -0
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-leader/references/planning.md +109 -0
- package/skills/yui-leader/references/task-plugins.md +8 -4
- package/skills/yui-operator/SKILL.md +22 -4
- package/skills/yui-runtime/SKILL.md +27 -0
- package/skills/yui-runtime/references/publication.md +20 -0
|
@@ -8,6 +8,7 @@ import { createTaskEvent } from "../event/taskEvent.js";
|
|
|
8
8
|
import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
|
|
9
9
|
import { sourceRunContextValue } from "../context/sourceRunContext.js";
|
|
10
10
|
import { requireManagedTaskCaller } from "../runtime/managedCaller.js";
|
|
11
|
+
import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
|
|
11
12
|
export function resolveMessageRecipient(store, taskId, roleName, scope) {
|
|
12
13
|
if (scope.reviewRoundId !== undefined) {
|
|
13
14
|
const round = store.getReviewRound(taskId, scope.reviewRoundId);
|
|
@@ -95,12 +96,104 @@ export function messageContinuationBlocker(store, message) {
|
|
|
95
96
|
}
|
|
96
97
|
return undefined;
|
|
97
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The one gate that decides when a claimed interrupt-then handoff may be
|
|
101
|
+
* delivered (decision-3 §4). It is keyed on the exact interrupted native Turn —
|
|
102
|
+
* not on the recipient's owner Assignment and not on a Run's business status —
|
|
103
|
+
* so the terminal proof is about the Turn that was actually cancelled:
|
|
104
|
+
* - `ready` — the native Turn stopped and its cancel outcome is knowable; the
|
|
105
|
+
* handoff may deliver.
|
|
106
|
+
* - `waiting` — the native Turn is still in flight; hold silently until it stops.
|
|
107
|
+
* - `unknown` — the cancel outcome is unprovable (delivery-unknown); the handoff
|
|
108
|
+
* is never released or replayed across that boundary.
|
|
109
|
+
* - `missing` — the target can never prove its terminal.
|
|
110
|
+
* A plain queued Message (no claim) is always `ready` here; its own delivery
|
|
111
|
+
* gates live in messageContinuationBlocker.
|
|
112
|
+
*
|
|
113
|
+
* The proof is composite by necessity (message-5 gap C). Native execution
|
|
114
|
+
* termination is read from the live ProviderTurn, re-verified by attemptId,
|
|
115
|
+
* because a Run's business status can flip to failed while its native Turn is
|
|
116
|
+
* still running — the old AgentRun-only gate could release across a Turn that had
|
|
117
|
+
* not actually stopped. When the live binding no longer holds that exact Turn, the
|
|
118
|
+
* proof falls back to the durable native terminal observation keyed by the target
|
|
119
|
+
* attemptId — never to the AgentRun's business status and never to a replacement
|
|
120
|
+
* Turn's existence, both of which can be true while the target's native execution
|
|
121
|
+
* has not stopped (decision-3 §4/§8, "requested != stopped"). The cancel's
|
|
122
|
+
* *outcome* (clean vs delivery-unknown) lives on the owning AgentRun's
|
|
123
|
+
* failureReason when a Run owns the Turn, because a settled ProviderTurn — or a
|
|
124
|
+
* durable native terminal — preserves only completed/failed/cancelled and cannot
|
|
125
|
+
* itself carry delivery-unknown.
|
|
126
|
+
*/
|
|
127
|
+
export function interruptThenTerminalState(store, message) {
|
|
128
|
+
const claim = message.interruptThen;
|
|
129
|
+
if (claim === undefined)
|
|
130
|
+
return "ready";
|
|
131
|
+
const roleName = claim.targetRoleName;
|
|
132
|
+
const sessions = store.getTaskRoleSessionSet(message.taskId, roleName);
|
|
133
|
+
const session = sessions?.sessions[sessions.activeAgentId];
|
|
134
|
+
const binding = sessions?.providerBinding;
|
|
135
|
+
if (session?.status !== "active" || session.nativeSessionId !== claim.targetNativeSessionId
|
|
136
|
+
|| session.agentId !== claim.targetAgentId || session.adapterId !== claim.targetAdapterId
|
|
137
|
+
|| binding?.authority.owner !== "controller"
|
|
138
|
+
|| binding.authority.epoch !== claim.targetAuthorityEpoch
|
|
139
|
+
|| binding.authority.holderId !== claim.targetAuthorityHolderId)
|
|
140
|
+
return "missing";
|
|
141
|
+
const turn = binding.run;
|
|
142
|
+
const owner = claim.targetRunId === undefined ? null : store.getRun(message.taskId, claim.targetRunId);
|
|
143
|
+
if (claim.targetRunId !== undefined && owner === null)
|
|
144
|
+
return "missing";
|
|
145
|
+
// Primary proof: the exact interrupted native Turn, identity re-verified by
|
|
146
|
+
// attemptId. While it is still present — in flight (submitting/accepted) or its
|
|
147
|
+
// own acceptance unproven (delivery-unknown) — the native execution has not
|
|
148
|
+
// stopped, so the handoff waits no matter what the Run's status claims.
|
|
149
|
+
if (turn !== null && turn.attemptId === claim.targetAttemptId) {
|
|
150
|
+
if (["submitting", "accepted", "delivery-unknown"].includes(turn.status))
|
|
151
|
+
return "waiting";
|
|
152
|
+
// The native Turn stopped. Its cancel outcome may still be unprovable; that
|
|
153
|
+
// is recorded on the owning AgentRun, never on the settled ProviderTurn.
|
|
154
|
+
return ["completed", "failed", "cancelled"].includes(turn.status)
|
|
155
|
+
&& owner?.result?.failureReason !== "delivery-unknown" ? "ready" : "unknown";
|
|
156
|
+
}
|
|
157
|
+
// The live binding no longer holds the target native Turn — a later Turn
|
|
158
|
+
// occupies it, or the binding's run pointer is gone. decision-3 §4/§8: a
|
|
159
|
+
// replacement Turn's mere existence is NOT proof the target's native execution
|
|
160
|
+
// stopped ("requested != stopped"; native quiescence is never inferred from a
|
|
161
|
+
// new Turn's existence, and never from the owning AgentRun's business status).
|
|
162
|
+
// The only protocol-proven stop is the durable, immutable native terminal
|
|
163
|
+
// observation keyed by the exact target attemptId, written when the Provider
|
|
164
|
+
// Host observed the Turn actually terminate.
|
|
165
|
+
const nativeStopped = store.listEvents(message.taskId)
|
|
166
|
+
.map(runtimeObservationFromTaskEvent)
|
|
167
|
+
.some((observation) => observation !== null
|
|
168
|
+
&& (observation.kind === "turn.completed" || observation.kind === "turn.failed"
|
|
169
|
+
|| observation.kind === "turn.cancelled")
|
|
170
|
+
&& observation.fence.roleName === roleName
|
|
171
|
+
&& observation.fence.nativeSessionId === claim.targetNativeSessionId
|
|
172
|
+
&& (claim.targetNativeTurnId === undefined || observation.fence.nativeTurnId === claim.targetNativeTurnId)
|
|
173
|
+
&& observation.fence.receiptId === claim.targetAttemptId);
|
|
174
|
+
if (nativeStopped) {
|
|
175
|
+
// Proven stopped. Its cancel outcome (clean vs delivery-unknown) is recorded on
|
|
176
|
+
// the owning AgentRun, never on the settled native terminal itself — the same
|
|
177
|
+
// composite the primary path reads once native termination is proven.
|
|
178
|
+
return owner?.result?.failureReason === "delivery-unknown" ? "unknown" : "ready";
|
|
179
|
+
}
|
|
180
|
+
// No durable proof the target native Turn stopped. Hold silently while an owning
|
|
181
|
+
// Run could still produce that terminal; a later business-terminal or a
|
|
182
|
+
// replacement Turn must never release it. With nothing owning the Turn and no
|
|
183
|
+
// durable terminal, its stop is unobservable and the claim can never prove a
|
|
184
|
+
// safe boundary — never released or replayed either way.
|
|
185
|
+
return owner !== null ? "waiting" : "missing";
|
|
186
|
+
}
|
|
98
187
|
/** Called in the Controller's ordinary reconciliation transaction. Messages
|
|
99
188
|
* remain the pending authority; Mailbox is only the existing scheduling hint.
|
|
100
189
|
* Reserving inputs and creating the next Run is one atomic effect. */
|
|
101
190
|
export function prepareMessageContinuations(store, taskId, now, roleName) {
|
|
102
191
|
const pending = store.listMessages(taskId).filter((message) => message.recipient?.roleName === roleName && message.recipient.ownerRunId !== undefined
|
|
103
|
-
&& message.continuation?.runId === undefined
|
|
192
|
+
&& message.continuation?.runId === undefined
|
|
193
|
+
// A steer targets the exact current native turn, never a queued next Run.
|
|
194
|
+
// If its live attempt did not submit, the message stays saved for the
|
|
195
|
+
// Leader to re-choose (§1/§8); Core never silently turns it into a queue.
|
|
196
|
+
&& message.inputControl?.action !== "steer");
|
|
104
197
|
for (const message of pending) {
|
|
105
198
|
const recipient = message.recipient;
|
|
106
199
|
const blocker = messageContinuationBlocker(store, message);
|
|
@@ -138,8 +231,38 @@ export function prepareMessageContinuations(store, taskId, now, roleName) {
|
|
|
138
231
|
? "owner-session-unobserved" : "owner-session-changed");
|
|
139
232
|
continue;
|
|
140
233
|
}
|
|
141
|
-
|
|
142
|
-
|
|
234
|
+
// decision-3 §4 ordering. Split this recipient's deliverable Messages into
|
|
235
|
+
// the explicit interrupt-then handoffs and the ordinary queue. A handoff is
|
|
236
|
+
// released only after its exact interrupted AgentRun reaches a proven
|
|
237
|
+
// terminal, and the ordinary queue must never preempt a live handoff.
|
|
238
|
+
const recipientPending = pending.filter((entry) => isDeepStrictEqual(entry.recipient, recipient)
|
|
239
|
+
&& messageContinuationBlocker(store, entry) === undefined);
|
|
240
|
+
const thenClaims = recipientPending.filter((entry) => entry.interruptThen !== undefined);
|
|
241
|
+
let handoffWaiting = false;
|
|
242
|
+
const readyThen = [];
|
|
243
|
+
for (const claim of thenClaims) {
|
|
244
|
+
const state = interruptThenTerminalState(store, claim);
|
|
245
|
+
if (state === "ready")
|
|
246
|
+
readyThen.push(claim);
|
|
247
|
+
else if (state === "waiting")
|
|
248
|
+
handoffWaiting = true;
|
|
249
|
+
// A claim whose target can never prove its terminal fails visibly and
|
|
250
|
+
// stops holding the queue; it is never released or replayed.
|
|
251
|
+
else
|
|
252
|
+
markNotDelivered(store, claim, state === "unknown"
|
|
253
|
+
? "interrupt-then-target-delivery-unknown" : "interrupt-then-target-missing");
|
|
254
|
+
}
|
|
255
|
+
// Hold the entire recipient — including the ordinary queue — while any
|
|
256
|
+
// handoff still awaits its interrupted Turn's proven terminal.
|
|
257
|
+
if (handoffWaiting)
|
|
258
|
+
continue;
|
|
259
|
+
// A ready handoff delivers in its own round ahead of the ordinary queue, so
|
|
260
|
+
// the old queue only follows once the handoff has been delivered.
|
|
261
|
+
const batch = (readyThen.length > 0
|
|
262
|
+
? readyThen
|
|
263
|
+
: recipientPending.filter((entry) => entry.interruptThen === undefined)).slice(0, 16);
|
|
264
|
+
if (batch.length === 0)
|
|
265
|
+
continue;
|
|
143
266
|
const previous = store.listRuns(taskId).filter((run) => run.roleName === owner.roleName
|
|
144
267
|
&& run.workItemId === owner.workItemId && run.reviewRoundId === owner.reviewRoundId).at(-1);
|
|
145
268
|
const baselineRef = previous.inputs[0]?.input.contextSnapshotRef;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createTaskEvent } from "../event/taskEvent.js";
|
|
2
|
+
/** Interrupt attempts are execution evidence, not a second message queue. */
|
|
3
|
+
export function findTaskInterrupt(store, taskId, requestId) {
|
|
4
|
+
return store.listEvents(taskId).find(event => event.type === "input.interrupt-requested" && event.payload.requestId === requestId);
|
|
5
|
+
}
|
|
6
|
+
export function reserveTaskInterrupt(store, taskId, requestId, fingerprint, target, thenMessageId, now) {
|
|
7
|
+
const receiptId = `interrupt:${taskId}/${requestId}`;
|
|
8
|
+
store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, "input.interrupt-requested", {
|
|
9
|
+
requestId, receiptId, fingerprint, roleName: target.roleName,
|
|
10
|
+
nativeSessionId: target.nativeSessionId, attemptId: target.attemptId,
|
|
11
|
+
target: JSON.stringify(target),
|
|
12
|
+
...(thenMessageId === undefined ? {} : { thenMessageId })
|
|
13
|
+
}, now));
|
|
14
|
+
return receiptId;
|
|
15
|
+
}
|
|
16
|
+
export function recordTaskInterruptResult(store, taskId, receiptId, receipt, now = new Date()) {
|
|
17
|
+
store.transaction(tx => {
|
|
18
|
+
const requested = tx.listEvents(taskId).find(event => event.type === "input.interrupt-requested" && event.payload.receiptId === receiptId);
|
|
19
|
+
if (requested === undefined)
|
|
20
|
+
throw new Error("Interrupt receipt has no original request.");
|
|
21
|
+
if (tx.listEvents(taskId).some(event => event.type === "input.interrupt-result" && event.payload.receiptId === receiptId))
|
|
22
|
+
return;
|
|
23
|
+
tx.saveEvent(taskId, createTaskEvent(tx.nextEventId(taskId), taskId, "input.interrupt-result", { receiptId, receipt: JSON.stringify(receipt) }, now));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function taskInterruptReceipt(store, taskId, receiptId) {
|
|
27
|
+
const event = store.listEvents(taskId).find(entry => entry.type === "input.interrupt-result" && entry.payload.receiptId === receiptId);
|
|
28
|
+
return event === undefined ? { state: "interrupt-unknown", receiptId }
|
|
29
|
+
: JSON.parse(event.payload.receipt);
|
|
30
|
+
}
|
|
31
|
+
export function taskInterruptWasRejected(store, taskId, receiptId) {
|
|
32
|
+
const receipt = taskInterruptReceipt(store, taskId, receiptId);
|
|
33
|
+
return receipt.outcome === "rejected" && receipt.state === "interrupt-unavailable";
|
|
34
|
+
}
|
|
@@ -135,7 +135,7 @@ function terminalWorkspaceCount(facts) {
|
|
|
135
135
|
}).length;
|
|
136
136
|
}
|
|
137
137
|
function terminalStatus(status) {
|
|
138
|
-
return status !== undefined && !["pending", "running", "awaiting_acceptance", "blocked", "validating"].includes(status);
|
|
138
|
+
return status !== undefined && !["pending", "running", "awaiting_acceptance", "blocked", "conflicted", "validating"].includes(status);
|
|
139
139
|
}
|
|
140
140
|
function counts(values) {
|
|
141
141
|
const result = {};
|
|
@@ -350,9 +350,17 @@ export function createPluginService(store, host, registry) {
|
|
|
350
350
|
if (previous) {
|
|
351
351
|
const retired = previous;
|
|
352
352
|
void host.detach(retired.ref).catch((error) => {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
// A post-publication detach failure is plugin operational telemetry,
|
|
354
|
+
// recorded on the plugin's own failure channel — not a Task product
|
|
355
|
+
// artifact (product artifacts are files in the Task's Git repository).
|
|
356
|
+
try {
|
|
357
|
+
store.recordPluginIntentFailure(taskId, retired.ref.id, intent.revision, {
|
|
358
|
+
message: `Plugin instance cleanup failed for ${retired.ref.id}/${retired.ref.generation}: `
|
|
359
|
+
+ (error instanceof Error ? error.message : String(error)),
|
|
360
|
+
occurredAt: new Date().toISOString()
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch { /* best-effort telemetry; never mask the successful activation */ }
|
|
356
364
|
});
|
|
357
365
|
}
|
|
358
366
|
return { provider: ref, validationId, digest: pkg.digest, capabilities: entries.map((entry) => entry.name),
|
|
@@ -121,6 +121,28 @@ async function activateLocked(ports, locked) {
|
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
void beginResult;
|
|
124
|
+
try {
|
|
125
|
+
ports.runPreflight(releaseDir, home);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
try {
|
|
129
|
+
await ports.call(home, "controller.rollback-handover", { handoverId });
|
|
130
|
+
}
|
|
131
|
+
catch (rollbackError) {
|
|
132
|
+
return {
|
|
133
|
+
outcome: "aborted", phase: "preflight",
|
|
134
|
+
message: `Host compatibility failed: ${messageOf(error)}; rollback failed: ${messageOf(rollbackError)}`,
|
|
135
|
+
action: "No candidate was started and the release pointer is unchanged. Preserve the fence and inspect the old Controller.",
|
|
136
|
+
recoverable: true
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
outcome: "aborted", phase: "preflight",
|
|
141
|
+
message: `Fenced compatibility preflight failed: ${messageOf(error)}`,
|
|
142
|
+
action: "The old Controller is serving again; the active release and Home are unchanged.",
|
|
143
|
+
recoverable: true
|
|
144
|
+
};
|
|
145
|
+
}
|
|
124
146
|
// 5) Start the candidate and wait for its identity read-back.
|
|
125
147
|
ports.spawnCandidate(home, releaseDir, handoverId);
|
|
126
148
|
const candidateReady = await waitForCandidateReady(ports, home, manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { releaseWorkflowScratchRoot } from "../storage/homeLayout.js";
|
|
5
5
|
import { runUpdate } from "../cli/updateOrchestrator.js";
|
|
6
6
|
import { activatedControllerEntrypoint } from "../cli/updatePorts.js";
|
|
7
7
|
import { restartFileTaskController, stopFileTaskController } from "../controller/clientRuntime.js";
|
|
@@ -581,7 +581,7 @@ export function createReleaseWorkflowPorts(deps) {
|
|
|
581
581
|
// publish` read THIS snapshot, so a TOCTOU replacement of the
|
|
582
582
|
// original path after the integrity check cannot change what is
|
|
583
583
|
// published. The snapshot is removed once publish completes.
|
|
584
|
-
const snapshot = await writeVerifiedTarballSnapshot(bytes);
|
|
584
|
+
const snapshot = await writeVerifiedTarballSnapshot(deps.home, bytes);
|
|
585
585
|
try {
|
|
586
586
|
// Inspect the tarball manifest before publishing: the actual
|
|
587
587
|
// package name/version must match the frozen declaration, not
|
|
@@ -727,8 +727,12 @@ export function createReleaseWorkflowPorts(deps) {
|
|
|
727
727
|
// P2-2 (rr19): Install in a fresh temp directory with an isolated
|
|
728
728
|
// npm cache, so npx cannot reuse a local node_modules binary. Then
|
|
729
729
|
// run the installed binary directly and verify the output matches
|
|
730
|
-
// the pinned version.
|
|
731
|
-
|
|
730
|
+
// the pinned version. The smoke directory is a Yui-authored release
|
|
731
|
+
// artifact, so it lands under the Home release-workflow scratch root,
|
|
732
|
+
// never a shared system temp root.
|
|
733
|
+
const scratch = releaseWorkflowScratchRoot(deps.home);
|
|
734
|
+
await mkdir(scratch, { recursive: true, mode: 0o700 });
|
|
735
|
+
const smokeDir = await mkdtemp(join(scratch, "yui-smoke-"));
|
|
732
736
|
const smokeCache = join(smokeDir, "npm-cache");
|
|
733
737
|
try {
|
|
734
738
|
const install = await run("npm", [
|
|
@@ -1642,10 +1646,14 @@ async function queryControllerLifecycle(run, globalYui, home, expectedVersion) {
|
|
|
1642
1646
|
* random name and read-only permissions (P1-3, rr22). The manifest
|
|
1643
1647
|
* inspection and `npm publish` both read this snapshot, so replacing the
|
|
1644
1648
|
* original path after the integrity check cannot change what is published.
|
|
1645
|
-
* The caller removes the snapshot once publish completes.
|
|
1649
|
+
* The caller removes the snapshot once publish completes. The snapshot is a
|
|
1650
|
+
* Yui-authored release artifact, so it lands under the Home release-workflow
|
|
1651
|
+
* scratch root, never a shared system temp root.
|
|
1646
1652
|
*/
|
|
1647
|
-
async function writeVerifiedTarballSnapshot(bytes) {
|
|
1648
|
-
const
|
|
1653
|
+
async function writeVerifiedTarballSnapshot(home, bytes) {
|
|
1654
|
+
const scratch = releaseWorkflowScratchRoot(home);
|
|
1655
|
+
await mkdir(scratch, { recursive: true, mode: 0o700 });
|
|
1656
|
+
const snapshot = join(scratch, `yui-release-snapshot-${randomBytes(12).toString("hex")}.tgz`);
|
|
1649
1657
|
// "wx" fails if the random name already exists, so a pre-existing file
|
|
1650
1658
|
// (or symlink) can never be overwritten or followed.
|
|
1651
1659
|
await writeFile(snapshot, bytes, { flag: "wx" });
|
|
@@ -679,7 +679,10 @@ export class NodeGitWorkspace {
|
|
|
679
679
|
return this.#ensureManagedWorktree({
|
|
680
680
|
repositoryPath: input.repositoryPath,
|
|
681
681
|
container: input.container,
|
|
682
|
-
identity:
|
|
682
|
+
identity: {
|
|
683
|
+
directory: input.directory,
|
|
684
|
+
branch: worktreeIdentity(input.taskSegment, input.roleName).branch
|
|
685
|
+
},
|
|
683
686
|
baseRef: input.baseRef
|
|
684
687
|
});
|
|
685
688
|
}
|
|
@@ -687,7 +690,10 @@ export class NodeGitWorkspace {
|
|
|
687
690
|
return this.#ensureManagedWorktree({
|
|
688
691
|
repositoryPath: input.repositoryPath,
|
|
689
692
|
container: input.container,
|
|
690
|
-
identity:
|
|
693
|
+
identity: {
|
|
694
|
+
directory: input.directory,
|
|
695
|
+
branch: integrationWorktreeIdentity(input.taskSegment, input.integrationId).branch
|
|
696
|
+
},
|
|
691
697
|
baseRef: input.baseRef,
|
|
692
698
|
allowRebase: true
|
|
693
699
|
});
|
|
@@ -734,19 +740,19 @@ export class NodeGitWorkspace {
|
|
|
734
740
|
if (state === "dirty")
|
|
735
741
|
return state;
|
|
736
742
|
const container = resolve(input.container);
|
|
737
|
-
const
|
|
738
|
-
const path = managedPath(container,
|
|
743
|
+
const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
744
|
+
const path = managedPath(container, input.directory);
|
|
739
745
|
const project = await this.inspect(input.repositoryPath);
|
|
740
746
|
if (state === "missing") {
|
|
741
747
|
if (input.deleteBranch === true) {
|
|
742
|
-
await
|
|
743
|
-
await deleteBranchIfPresent(project.root,
|
|
748
|
+
await removeMissingWorktreeRegistration(project.root, path, branch);
|
|
749
|
+
await deleteBranchIfPresent(project.root, branch);
|
|
744
750
|
}
|
|
745
751
|
return state;
|
|
746
752
|
}
|
|
747
753
|
await git(["-C", project.root, "worktree", "remove", "--", path]);
|
|
748
754
|
if (input.deleteBranch === true) {
|
|
749
|
-
await deleteBranchIfPresent(project.root,
|
|
755
|
+
await deleteBranchIfPresent(project.root, branch);
|
|
750
756
|
}
|
|
751
757
|
return "removed";
|
|
752
758
|
}
|
|
@@ -779,6 +785,36 @@ export class NodeGitWorkspace {
|
|
|
779
785
|
}
|
|
780
786
|
return "removed";
|
|
781
787
|
}
|
|
788
|
+
async removeTaskClone(input) {
|
|
789
|
+
const identity = worktreeIdentity(input.taskSegment, "main");
|
|
790
|
+
const expected = managedPath(resolve(input.container), input.directory);
|
|
791
|
+
if (resolve(input.path) !== expected || input.branch !== identity.branch) {
|
|
792
|
+
throw new Error("Task clone does not match its recorded managed identity.");
|
|
793
|
+
}
|
|
794
|
+
const kind = await pathKind(expected);
|
|
795
|
+
if (kind === undefined)
|
|
796
|
+
return "missing";
|
|
797
|
+
if (kind === "symlink")
|
|
798
|
+
throw new Error("Task clone must not be a symbolic link.");
|
|
799
|
+
await canonicalContainer(input.container, false);
|
|
800
|
+
if (await realpath(expected) !== expected || await pathKind(join(expected, ".git")) !== "directory") {
|
|
801
|
+
throw new Error("Task clone is not a standalone owned Git directory.");
|
|
802
|
+
}
|
|
803
|
+
const repository = await this.inspect(expected);
|
|
804
|
+
if (repository.root !== expected || repository.gitDirectory !== join(expected, ".git")) {
|
|
805
|
+
throw new Error("Task clone Git ownership cannot be established.");
|
|
806
|
+
}
|
|
807
|
+
await assertExpectedBranch(expected, input.branch);
|
|
808
|
+
if (!await this.isClean(expected))
|
|
809
|
+
return "dirty";
|
|
810
|
+
const worktrees = (await git(["-C", expected, "worktree", "list", "--porcelain", "-z"]))
|
|
811
|
+
.split("\0").filter(line => line.startsWith("worktree "));
|
|
812
|
+
if (worktrees.length !== 1 || worktrees[0] !== `worktree ${expected}`) {
|
|
813
|
+
throw new Error("Task clone still owns Git worktree registrations; retained.");
|
|
814
|
+
}
|
|
815
|
+
await rm(expected, { recursive: true });
|
|
816
|
+
return "removed";
|
|
817
|
+
}
|
|
782
818
|
async inspectRecordedWorktree(input) {
|
|
783
819
|
const inspected = await inspectExactRecordedWorktree(input);
|
|
784
820
|
return inspected?.state ?? "missing";
|
|
@@ -824,7 +860,7 @@ export class NodeGitWorkspace {
|
|
|
824
860
|
}
|
|
825
861
|
async inspectWorktree(input) {
|
|
826
862
|
const container = resolve(input.container);
|
|
827
|
-
const path = managedPath(container,
|
|
863
|
+
const path = managedPath(container, input.directory);
|
|
828
864
|
const kind = await pathKind(path);
|
|
829
865
|
if (kind === undefined)
|
|
830
866
|
return "missing";
|
|
@@ -840,7 +876,10 @@ export class NodeGitWorkspace {
|
|
|
840
876
|
return this.#removeManagedWorktree({
|
|
841
877
|
repositoryPath: input.repositoryPath,
|
|
842
878
|
container: input.container,
|
|
843
|
-
identity:
|
|
879
|
+
identity: {
|
|
880
|
+
directory: input.directory,
|
|
881
|
+
branch: integrationWorktreeIdentity(input.taskSegment, input.integrationId).branch
|
|
882
|
+
},
|
|
844
883
|
discardChanges: input.discardChanges
|
|
845
884
|
});
|
|
846
885
|
}
|
|
@@ -851,6 +890,9 @@ export class NodeGitWorkspace {
|
|
|
851
890
|
if (kind === "symlink")
|
|
852
891
|
throw new Error("Managed worktree path must not be a symbolic link.");
|
|
853
892
|
const repository = await this.inspect(input.repositoryPath);
|
|
893
|
+
if (kind === undefined) {
|
|
894
|
+
await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch);
|
|
895
|
+
}
|
|
854
896
|
if (kind === "directory") {
|
|
855
897
|
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
856
898
|
await assertOwnedWorktree(repository, canonicalContainerPath, path);
|
|
@@ -884,6 +926,21 @@ export class NodeGitWorkspace {
|
|
|
884
926
|
return kind === "directory" ? "removed" : "missing";
|
|
885
927
|
}
|
|
886
928
|
}
|
|
929
|
+
/** Missing paths do not imply missing Git metadata. Never prune another
|
|
930
|
+
* workspace's registration as a side effect of cleaning this exact owner.
|
|
931
|
+
*/
|
|
932
|
+
async function removeMissingWorktreeRegistration(repositoryRoot, path, branch) {
|
|
933
|
+
const records = (await git(["-C", repositoryRoot, "worktree", "list", "--porcelain", "-z"])).split("\0\0");
|
|
934
|
+
const fields = records.map(record => record.split("\0")).find(record => record.includes(`worktree ${path}`));
|
|
935
|
+
if (fields === undefined)
|
|
936
|
+
return;
|
|
937
|
+
if (!fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field.startsWith("locked"))) {
|
|
938
|
+
throw new Error(`Missing worktree has uncertain or locked Git ownership: ${path}; metadata retained.`);
|
|
939
|
+
}
|
|
940
|
+
if (await pathKind(path) !== undefined)
|
|
941
|
+
throw new Error(`Worktree reappeared before metadata cleanup: ${path}.`);
|
|
942
|
+
await git(["-C", repositoryRoot, "worktree", "remove", "--force", "--", path]);
|
|
943
|
+
}
|
|
887
944
|
async function deleteBranchIfPresent(repositoryRoot, branch) {
|
|
888
945
|
const branchRef = `refs/heads/${branch}`;
|
|
889
946
|
if (!await gitSucceeds([
|
|
@@ -960,9 +1017,9 @@ async function assertOwnedWorktree(project, container, path) {
|
|
|
960
1017
|
}
|
|
961
1018
|
async function inspectExactRecordedWorktree(input) {
|
|
962
1019
|
const container = resolve(input.container);
|
|
963
|
-
const
|
|
964
|
-
const expectedPath = managedPath(container,
|
|
965
|
-
if (resolve(input.path) !== expectedPath || input.branch !==
|
|
1020
|
+
const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1021
|
+
const expectedPath = managedPath(container, input.directory);
|
|
1022
|
+
if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
|
|
966
1023
|
throw new Error("Recorded managed worktree identity is invalid.");
|
|
967
1024
|
}
|
|
968
1025
|
const kind = await pathKind(expectedPath);
|
|
@@ -1023,9 +1080,9 @@ async function resolveRefCommit(repositoryPath, ref) {
|
|
|
1023
1080
|
async function recordedWorktreeWithoutRepository(input, cause) {
|
|
1024
1081
|
if (await pathKind(input.repositoryPath) !== undefined)
|
|
1025
1082
|
throw cause;
|
|
1026
|
-
const
|
|
1027
|
-
const expectedPath = managedPath(resolve(input.container),
|
|
1028
|
-
if (resolve(input.path) !== expectedPath || input.branch !==
|
|
1083
|
+
const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1084
|
+
const expectedPath = managedPath(resolve(input.container), input.directory);
|
|
1085
|
+
if (resolve(input.path) !== expectedPath || input.branch !== branch) {
|
|
1029
1086
|
throw cause;
|
|
1030
1087
|
}
|
|
1031
1088
|
if (await pathKind(expectedPath) === undefined)
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { isArchivePersistenceFailure, recordArchiveCleanup } from "../task/archiveDiagnostics.js";
|
|
4
|
+
import { projectTaskRemoteDeliveryFromStore } from "../commands/taskRemoteDeliveryCommand.js";
|
|
5
|
+
import { WorkItemChangeSetManager } from "../workspace/workItemChangeSetManager.js";
|
|
2
6
|
import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
3
7
|
import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
|
|
4
8
|
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
@@ -247,6 +251,7 @@ export class TaskWorkspaceCoordinator {
|
|
|
247
251
|
if (attempt === null
|
|
248
252
|
|| attempt.status === "running"
|
|
249
253
|
|| attempt.status === "blocked"
|
|
254
|
+
|| attempt.status === "conflicted"
|
|
250
255
|
|| attempt.status === "validating") {
|
|
251
256
|
throw new Error(`IntegrationAttempt must be terminal before archive cleanup: ${workspace.owner.integrationAttemptId}.`);
|
|
252
257
|
}
|
|
@@ -330,6 +335,135 @@ export class TaskWorkspaceCoordinator {
|
|
|
330
335
|
releaseMaintenance?.();
|
|
331
336
|
}
|
|
332
337
|
}
|
|
338
|
+
/** Best-effort cleanup only after explicit force admission has committed.
|
|
339
|
+
* This is one foreground attempt, not a retry worker. Each independent result
|
|
340
|
+
* is durable before continuing, and a failed audit write really fails.
|
|
341
|
+
*/
|
|
342
|
+
async cleanupArchivedTask(taskId, disposition) {
|
|
343
|
+
const task = this.store.getTask(taskId);
|
|
344
|
+
if (task?.status !== "archived"
|
|
345
|
+
|| !this.store.listEvents(taskId).some(e => e.type === "task.archived" && e.payload.force === "true")) {
|
|
346
|
+
throw new Error(`Force archive must commit before cleanup: ${taskId}.`);
|
|
347
|
+
}
|
|
348
|
+
const attempt = async (diagnostic, action) => {
|
|
349
|
+
recordArchiveCleanup(this.store, taskId, diagnostic, "started");
|
|
350
|
+
let status;
|
|
351
|
+
let detail = diagnostic.detail;
|
|
352
|
+
try {
|
|
353
|
+
const result = await action();
|
|
354
|
+
status = result === "dirty" ? "retained" : result;
|
|
355
|
+
if (result === "dirty")
|
|
356
|
+
detail = "Dirty workspace retained; force archive does not discard local changes.";
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (isArchivePersistenceFailure(error))
|
|
360
|
+
throw error;
|
|
361
|
+
status = "retained";
|
|
362
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
363
|
+
}
|
|
364
|
+
recordArchiveCleanup(this.store, taskId, { ...diagnostic, detail }, status);
|
|
365
|
+
return status !== "retained";
|
|
366
|
+
};
|
|
367
|
+
let runtimeReleased = true;
|
|
368
|
+
for (const role of this.store.listRoles(taskId)) {
|
|
369
|
+
const released = await attempt({ resource: `role:${taskId}/${role.name}`,
|
|
370
|
+
detail: "Exact Role runtime stop." }, async () => {
|
|
371
|
+
const provider = this.store.getTaskRoleSessionSet(taskId, role.name)?.providerBinding;
|
|
372
|
+
const mailbox = this.store.getWorkMailbox({ kind: "role", taskId, roleName: role.name });
|
|
373
|
+
if (mailbox?.processing != null && (provider?.run == null
|
|
374
|
+
|| !["completed", "failed", "cancelled"].includes(provider.run.status))) {
|
|
375
|
+
throw new Error(`Original mailbox claim ${mailbox.processing.batchId} retained; execution outcome is not known.`);
|
|
376
|
+
}
|
|
377
|
+
if (provider?.run != null && !["completed", "failed", "cancelled"].includes(provider.run.status)) {
|
|
378
|
+
throw new Error(`Provider input ${provider.run.attemptId}/${provider.run.status} retained; establish exact quiescence before resolving it.`);
|
|
379
|
+
}
|
|
380
|
+
await this.#stopLiveRoles(taskId, [role.name]);
|
|
381
|
+
return "released";
|
|
382
|
+
});
|
|
383
|
+
runtimeReleased &&= released;
|
|
384
|
+
}
|
|
385
|
+
const physicalReleased = await attempt({ resource: `runtime:${taskId}`,
|
|
386
|
+
detail: "Verify exact physical resource release before workspace deletion." }, async () => {
|
|
387
|
+
const activeRun = this.store.listRuns(taskId).find(r => r.status === "active");
|
|
388
|
+
const job = this.store.listDurableJobs(taskId).find(j => ["queued", "running", "unknown-needs-attention"].includes(j.status));
|
|
389
|
+
if (activeRun !== undefined || job !== undefined) {
|
|
390
|
+
throw new Error(`Execution resources retained: ${activeRun?.id ?? job?.id}; archive is not proof of quiescence.`);
|
|
391
|
+
}
|
|
392
|
+
if (!runtimeReleased)
|
|
393
|
+
throw new Error("One or more Role runtimes could not be safely released.");
|
|
394
|
+
if (this.runtime.assertTaskPhysicalResourcesReleased === undefined) {
|
|
395
|
+
throw new Error("Physical resource release inspection is unavailable; workspace references retained.");
|
|
396
|
+
}
|
|
397
|
+
await this.runtime.assertTaskPhysicalResourcesReleased(taskId);
|
|
398
|
+
return "released";
|
|
399
|
+
});
|
|
400
|
+
if (physicalReleased) {
|
|
401
|
+
// Task main owns the Git objects of its dependent worktrees, so it is
|
|
402
|
+
// always last and remains when any dependent cleanup is unresolved.
|
|
403
|
+
const workspaces = this.store.listManagedWorkspaces(taskId).sort((a, b) => Number(a.owner.type === "task") - Number(b.owner.type === "task"));
|
|
404
|
+
for (const workspace of workspaces) {
|
|
405
|
+
await attempt({ resource: managedWorkspaceKey(workspace.owner),
|
|
406
|
+
detail: "Remove only clean, exactly owned workspace resources.",
|
|
407
|
+
paths: [workspace.root, ...workspace.entries.filter(e => e.access === "write").map(e => e.path)]
|
|
408
|
+
}, async () => {
|
|
409
|
+
const release = acquireProjectMaintenanceLocks(this.preparer.home, workspace.entries.map(e => e.projectId));
|
|
410
|
+
try {
|
|
411
|
+
if (!isDeepStrictEqual(this.store.getManagedWorkspace(workspace.owner), workspace)) {
|
|
412
|
+
throw new Error("Workspace ownership changed; retained.");
|
|
413
|
+
}
|
|
414
|
+
const owner = workspace.owner;
|
|
415
|
+
switch (owner.type) {
|
|
416
|
+
case "work-item": {
|
|
417
|
+
const item = this.store.getWorkItem(taskId, owner.workItemId);
|
|
418
|
+
if (item?.status === "accepted" && disposition === "integrated") {
|
|
419
|
+
await new WorkItemChangeSetManager(this.store).assertIntegrated(taskId, item.id);
|
|
420
|
+
}
|
|
421
|
+
return this.preparer.cleanupWorkItemWorkspace(taskId, owner.workItemId, item?.status === "retired" ? "abandoned" : disposition);
|
|
422
|
+
}
|
|
423
|
+
case "review-round":
|
|
424
|
+
return this.preparer.cleanupReviewRoundWorkspace(taskId, owner.reviewRoundId);
|
|
425
|
+
case "execution-lane":
|
|
426
|
+
return this.preparer.cleanupExecutionLaneWorkspace(taskId, owner.executionGroupId, owner.executionLaneId);
|
|
427
|
+
case "integration-attempt":
|
|
428
|
+
if (await this.preparer.inspectIntegrationWorkspace(taskId, owner.integrationAttemptId) === "dirty")
|
|
429
|
+
return "dirty";
|
|
430
|
+
return this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId);
|
|
431
|
+
case "task": {
|
|
432
|
+
const current = this.store.getTask(taskId);
|
|
433
|
+
const delivery = projectTaskRemoteDeliveryFromStore(this.store, current);
|
|
434
|
+
if (!delivery.allMerged || !delivery.allVerified) {
|
|
435
|
+
throw new Error("Task main retained: local commits are not covered by verified remote delivery. Force does not discard them.");
|
|
436
|
+
}
|
|
437
|
+
for (const entry of workspace.entries.filter(e => e.access === "write")) {
|
|
438
|
+
const expected = delivery.projects.find(p => p.projectId === entry.projectId)?.expectedLocalCommit;
|
|
439
|
+
const path = await lstat(entry.path).catch(error => {
|
|
440
|
+
if (error.code === "ENOENT")
|
|
441
|
+
return null;
|
|
442
|
+
throw error;
|
|
443
|
+
});
|
|
444
|
+
if (expected !== null && expected !== undefined
|
|
445
|
+
&& path !== null) {
|
|
446
|
+
const actual = await this.preparer.git.inspect(entry.path, "HEAD");
|
|
447
|
+
if (actual.baseCommit !== expected)
|
|
448
|
+
throw new Error(`Task main HEAD changed: ${entry.path}; retained.`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const result = await this.preparer.cleanupTaskForArchive(taskId);
|
|
452
|
+
if (result.status !== "removed")
|
|
453
|
+
throw new Error(result.error ?? "Task main or dependent workspace retained.");
|
|
454
|
+
return "removed";
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
release();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
recordArchiveCleanup(this.store, taskId, { resource: `task:${taskId}`,
|
|
465
|
+
detail: "Foreground cleanup attempt finished; inspect retained resource references and warnings." }, "finished");
|
|
466
|
+
}
|
|
333
467
|
#assertTaskArchiveSnapshot(snapshot) {
|
|
334
468
|
this.#assertTaskArchiveLifecycle(snapshot.task);
|
|
335
469
|
const managedWorkspaces = [...this.store.listManagedWorkspaces(snapshot.task.id)]
|