@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.
Files changed (151) hide show
  1. package/ARCHITECTURE.md +2 -0
  2. package/ARCHITECTURE.zh-CN.md +151 -0
  3. package/README.md +211 -14
  4. package/dist/agent/launchEnvironment.js +7 -0
  5. package/dist/artifacts/artifactCapability.js +74 -0
  6. package/dist/artifacts/artifactCommitLock.js +249 -0
  7. package/dist/artifacts/artifactPaths.js +151 -0
  8. package/dist/artifacts/gitArtifactRef.js +146 -0
  9. package/dist/artifacts/managedGit.js +332 -0
  10. package/dist/artifacts/taskArtifactRepository.js +277 -0
  11. package/dist/cli/commandCatalog.js +40 -16
  12. package/dist/cli/interactionPolicy.js +3 -3
  13. package/dist/cli/updateOrchestrator.js +24 -1
  14. package/dist/cli/updatePorts.js +7 -3
  15. package/dist/cli/upgradeCommand.js +42 -2
  16. package/dist/cli.js +403 -93
  17. package/dist/commands/globalRoleCommands.js +314 -4
  18. package/dist/commands/operatorCommands.js +33 -2
  19. package/dist/commands/projectCommands.js +6 -7
  20. package/dist/commands/releaseCommands.js +18 -0
  21. package/dist/commands/taskActivationCommands.js +22 -0
  22. package/dist/commands/taskActor.js +25 -0
  23. package/dist/commands/taskCommands.js +846 -155
  24. package/dist/commands/taskIntegrationCommands.js +16 -38
  25. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  26. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  27. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  28. package/dist/context/runContextPack.js +28 -16
  29. package/dist/context/taskContext.js +64 -5
  30. package/dist/controller/agentHostObservation.js +155 -0
  31. package/dist/controller/clientRuntime.js +17 -2
  32. package/dist/controller/controller.js +11 -2
  33. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  34. package/dist/controller/globalInputDelivery.js +119 -0
  35. package/dist/controller/jobControl.js +6 -2
  36. package/dist/controller/resourceInventory.js +14 -4
  37. package/dist/controller/resourceInventoryLinux.js +2 -6
  38. package/dist/controller/runtime.js +81 -6
  39. package/dist/controller/runtimeEventInbox.js +32 -3
  40. package/dist/controller/runtimeEventProcessor.js +26 -6
  41. package/dist/controller/runtimeHookRunFence.js +75 -19
  42. package/dist/controller/structuredProviderObservation.js +133 -70
  43. package/dist/coordination/workMailboxQueue.js +5 -0
  44. package/dist/execution/workItemExecutionProjection.js +1 -1
  45. package/dist/executor/agentExecutor.js +64 -4
  46. package/dist/executor/executorRegistry.js +3 -0
  47. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  48. package/dist/integration/deliveryObligation.js +2 -1
  49. package/dist/integration/gitIntegrationService.js +312 -382
  50. package/dist/integration/integrationAttempt.js +30 -4
  51. package/dist/integration/integrationQueueService.js +7 -7
  52. package/dist/integration/integrationSourceApplication.js +323 -0
  53. package/dist/kernel/builtinCapabilities.js +32 -24
  54. package/dist/message/globalInterrupt.js +33 -0
  55. package/dist/message/inputControlResolution.js +106 -0
  56. package/dist/message/message.js +423 -0
  57. package/dist/message/messageContinuation.js +126 -3
  58. package/dist/message/taskInterrupt.js +34 -0
  59. package/dist/observability/orchestrationMetrics.js +1 -1
  60. package/dist/plugins/pluginService.js +11 -3
  61. package/dist/release/releaseHandover.js +22 -0
  62. package/dist/release/releaseWorkflowPorts.js +15 -7
  63. package/dist/repository/gitWorkspace.js +72 -15
  64. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  65. package/dist/repository/taskWorkspacePreparer.js +120 -49
  66. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  67. package/dist/resources/projectResource.js +0 -48
  68. package/dist/resources/projectResourceService.js +3 -81
  69. package/dist/resources/resourceDiscovery.js +3 -2
  70. package/dist/runtime/agentHost.js +152 -72
  71. package/dist/runtime/agentHostCompatibility.js +127 -0
  72. package/dist/runtime/agentHostProtocol.js +53 -0
  73. package/dist/runtime/executionEnvironment.js +0 -19
  74. package/dist/runtime/launchBroker.js +6 -0
  75. package/dist/runtime/sessionReconciliation.js +4 -4
  76. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  77. package/dist/runtime/tmuxAdapters.js +5 -3
  78. package/dist/scheduler/operatorEvent.js +4 -0
  79. package/dist/scheduler/taskExecutionProjection.js +12 -1
  80. package/dist/scheduler/wakeReason.js +7 -1
  81. package/dist/scheduler/wakeupQueue.js +2 -0
  82. package/dist/setup/setupCommand.js +29 -16
  83. package/dist/storage/homeLayout.js +130 -0
  84. package/dist/storage/migrations/artifactsToGit.js +338 -0
  85. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  86. package/dist/storage/migrations/integrationContinuation.js +104 -0
  87. package/dist/storage/migrations/submitIntent.js +126 -0
  88. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  89. package/dist/storage/sqliteSchema.js +173 -7
  90. package/dist/storage/sqliteStore.js +41 -22
  91. package/dist/storage/storageVersions.js +1 -1
  92. package/dist/storage/storeRpc.js +2 -1
  93. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  94. package/dist/task/archiveDiagnostics.js +128 -0
  95. package/dist/task/nextAction.js +44 -11
  96. package/dist/task/taskActivation.js +26 -0
  97. package/dist/task/taskActivationService.js +85 -69
  98. package/dist/task/taskSubmission.js +236 -0
  99. package/dist/web/assets/client/app.js +58 -2
  100. package/dist/web/assets/client/components.js +1 -0
  101. package/dist/web/assets/client/i18n.js +6 -0
  102. package/dist/web/assets/client/taskSurface.js +202 -7
  103. package/dist/web/assets/client/view.js +7 -4
  104. package/dist/web/assets/shell.js +23 -0
  105. package/dist/web/assets/styles/layout.js +1 -1
  106. package/dist/web/assets/styles/widgets.js +12 -0
  107. package/dist/web/webServer.js +135 -4
  108. package/dist/web/webSnapshot.js +4 -3
  109. package/dist/web/webTaskSurface.js +225 -8
  110. package/dist/workItem/workItem.js +14 -10
  111. package/dist/workspace/workItemChangeSetManager.js +18 -2
  112. package/docs/agent-result-consumption.md +2 -0
  113. package/docs/agent-result-consumption.zh-CN.md +81 -0
  114. package/docs/agent-runtime-drivers.md +2 -0
  115. package/docs/agent-runtime-drivers.zh-CN.md +77 -0
  116. package/docs/architecture/README.md +44 -32
  117. package/docs/architecture/README.zh-CN.md +43 -0
  118. package/docs/architecture/capabilities-and-resources.md +118 -79
  119. package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
  120. package/docs/managed-turn-and-session-runtime.md +2 -0
  121. package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
  122. package/docs/observability/README.md +2 -0
  123. package/docs/observability/README.zh-CN.md +71 -0
  124. package/docs/plugin-sdk.md +320 -217
  125. package/docs/plugin-sdk.zh-CN.md +293 -0
  126. package/docs/provider-runtime.md +2 -0
  127. package/docs/provider-runtime.zh-CN.md +132 -0
  128. package/docs/release-workflow.md +41 -0
  129. package/docs/release-workflow.zh-CN.md +266 -0
  130. package/docs/roles-and-configuration.md +2 -0
  131. package/docs/roles-and-configuration.zh-CN.md +96 -0
  132. package/docs/sqlite-control-plane-design.md +225 -1
  133. package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
  134. package/docs/task-dag-semantics.md +80 -57
  135. package/docs/task-dag-semantics.zh-CN.md +59 -0
  136. package/docs/task-delivery.md +2 -0
  137. package/docs/task-delivery.zh-CN.md +82 -0
  138. package/docs/task-local-identity.md +2 -0
  139. package/docs/task-local-identity.zh-CN.md +58 -0
  140. package/docs/testing/verification-levels.md +26 -0
  141. package/docs/testing/verification-levels.zh-CN.md +80 -0
  142. package/i18n/README.zh-CN.md +199 -10
  143. package/package.json +2 -1
  144. package/skills/yui-leader/SKILL.md +88 -331
  145. package/skills/yui-leader/references/execution.md +405 -0
  146. package/skills/yui-leader/references/integration.md +52 -2
  147. package/skills/yui-leader/references/planning.md +109 -0
  148. package/skills/yui-leader/references/task-plugins.md +8 -4
  149. package/skills/yui-operator/SKILL.md +22 -4
  150. package/skills/yui-runtime/SKILL.md +27 -0
  151. 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
- const batch = pending.filter((entry) => isDeepStrictEqual(entry.recipient, recipient)
142
- && messageContinuationBlocker(store, entry) === undefined).slice(0, 16);
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
- resources.saveArtifact(taskId, { kind: "content", displayName: "Plugin instance cleanup failure",
354
- provenance: `PluginService.dispose ${retired.ref.id}/${retired.ref.generation}`,
355
- content: error instanceof Error ? error.message : String(error) });
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
- const smokeDir = await mkdtemp(join(tmpdir(), "yui-smoke-"));
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 snapshot = join(tmpdir(), `yui-release-snapshot-${randomBytes(12).toString("hex")}.tgz`);
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: worktreeIdentity(input.taskSegment, input.roleName),
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: integrationWorktreeIdentity(input.taskSegment, input.integrationId),
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 identity = worktreeIdentity(input.taskSegment, input.roleName);
738
- const path = managedPath(container, identity.directory);
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 git(["-C", project.root, "worktree", "prune"]);
743
- await deleteBranchIfPresent(project.root, identity.branch);
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, identity.branch);
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, worktreeIdentity(input.taskSegment, input.roleName).directory);
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: integrationWorktreeIdentity(input.taskSegment, input.integrationId),
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 identity = worktreeIdentity(input.taskSegment, input.roleName);
964
- const expectedPath = managedPath(container, identity.directory);
965
- if (resolve(input.path) !== expectedPath || input.branch !== identity.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 identity = worktreeIdentity(input.taskSegment, input.roleName);
1027
- const expectedPath = managedPath(resolve(input.container), identity.directory);
1028
- if (resolve(input.path) !== expectedPath || input.branch !== identity.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)]