@zq-silk/yui 0.15.11 → 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.
Files changed (101) hide show
  1. package/ARCHITECTURE.md +8 -4
  2. package/ARCHITECTURE.zh-CN.md +5 -2
  3. package/README.md +13 -5
  4. package/dist/agentRun/agentRun.js +3 -0
  5. package/dist/cli/commandCatalog.js +38 -10
  6. package/dist/cli/interactionPolicy.js +4 -0
  7. package/dist/cli/managedDiagnostics.js +1 -1
  8. package/dist/cli.js +46 -15
  9. package/dist/commands/executionAuditCommands.js +10 -0
  10. package/dist/commands/globalRoleCommands.js +27 -2
  11. package/dist/commands/projectCommands.js +44 -15
  12. package/dist/commands/taskCommands.js +95 -33
  13. package/dist/commands/taskIntegrationCommands.js +3 -1
  14. package/dist/commands/taskOverviewCommand.js +4 -3
  15. package/dist/commands/taskPublicationAdoptCommand.js +127 -0
  16. package/dist/commands/taskPublicationCommands.js +11 -2
  17. package/dist/commands/taskPublicationVerifyCommand.js +23 -39
  18. package/dist/commands/taskRemoteDeliveryCommand.js +18 -7
  19. package/dist/context/runContextPack.js +3 -0
  20. package/dist/context/taskCatalog.js +187 -0
  21. package/dist/context/taskContext.js +17 -4
  22. package/dist/controller/controller.js +11 -2
  23. package/dist/controller/fileSchedulerStoreAdapter.js +80 -19
  24. package/dist/controller/globalInputDelivery.js +13 -0
  25. package/dist/controller/providerRetryAdmission.js +100 -0
  26. package/dist/controller/providerRetryDelivery.js +218 -0
  27. package/dist/controller/runtime.js +36 -1
  28. package/dist/integration/gitIntegrationService.js +19 -6
  29. package/dist/lifecycle/exactRunTerminalization.js +4 -1
  30. package/dist/message/globalProviderRetry.js +15 -0
  31. package/dist/observability/executionAudit.js +19 -0
  32. package/dist/repository/gitWorkspace.js +371 -105
  33. package/dist/repository/projectMaintenanceLock.js +75 -18
  34. package/dist/repository/taskWorkspaceCoordinator.js +71 -124
  35. package/dist/repository/taskWorkspacePreparer.js +86 -24
  36. package/dist/repository/workspaceCleanupInspection.js +187 -0
  37. package/dist/runtime/agentError.js +5 -3
  38. package/dist/runtime/agentHost.js +28 -11
  39. package/dist/runtime/builtinAgentErrorMappers.js +91 -0
  40. package/dist/runtime/codexAppServerRuntime.js +34 -3
  41. package/dist/runtime/providerControl.js +5 -1
  42. package/dist/runtime/providerRetry.js +198 -0
  43. package/dist/runtime/providerRuntimeIdentity.js +28 -2
  44. package/dist/runtime/sessionTokenMetrics.js +15 -5
  45. package/dist/runtime/structuredProviderHost.js +6 -2
  46. package/dist/runtime/taskUsageMetrics.js +275 -0
  47. package/dist/scheduler/activeRoleRunDelivery.js +12 -0
  48. package/dist/scheduler/leaderWakeupProcessor.js +5 -0
  49. package/dist/scheduler/taskExecutionProjection.js +26 -5
  50. package/dist/scheduler/taskObservabilityProjection.js +6 -44
  51. package/dist/storage/sqliteSchema.js +31 -0
  52. package/dist/storage/sqliteStore.js +17 -0
  53. package/dist/storage/storageVersions.js +1 -1
  54. package/dist/storage/storeRpc.js +1 -0
  55. package/dist/storage/taskCatalog.js +123 -0
  56. package/dist/storage/taskStore.js +2 -0
  57. package/dist/task/archiveDiagnostics.js +1 -0
  58. package/dist/task/archivePreflight.js +124 -0
  59. package/dist/task/publicationAdoption.js +56 -0
  60. package/dist/task/publicationReference.js +10 -0
  61. package/dist/task/remoteDelivery.js +31 -16
  62. package/dist/web/assets/client/app.js +92 -17
  63. package/dist/web/assets/client/components.js +55 -13
  64. package/dist/web/assets/client/i18n.js +72 -4
  65. package/dist/web/assets/client/taskSurface.js +2 -1
  66. package/dist/web/assets/client/view.js +35 -7
  67. package/dist/web/assets/shell.js +6 -0
  68. package/dist/web/assets/styles/layout.js +7 -0
  69. package/dist/web/webServer.js +14 -3
  70. package/dist/web/webSnapshot.js +12 -3
  71. package/dist/workspace/cleanupInspection.js +63 -0
  72. package/dist/workspace/workItemChangeSetManager.js +110 -50
  73. package/docs/agent-result-consumption.md +4 -0
  74. package/docs/agent-result-consumption.zh-CN.md +3 -0
  75. package/docs/agent-runtime-drivers.md +7 -0
  76. package/docs/agent-runtime-drivers.zh-CN.md +5 -0
  77. package/docs/architecture/README.md +2 -0
  78. package/docs/architecture/README.zh-CN.md +3 -1
  79. package/docs/architecture/capabilities-and-resources.md +30 -5
  80. package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
  81. package/docs/managed-turn-and-session-runtime.md +47 -0
  82. package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
  83. package/docs/observability/README.md +62 -0
  84. package/docs/observability/README.zh-CN.md +47 -0
  85. package/docs/project-refresh.md +77 -0
  86. package/docs/project-refresh.zh-CN.md +59 -0
  87. package/docs/provider-retry.md +70 -0
  88. package/docs/task-delivery.md +133 -13
  89. package/docs/task-delivery.zh-CN.md +99 -10
  90. package/docs/task-discovery.md +102 -0
  91. package/docs/task-discovery.zh-CN.md +86 -0
  92. package/docs/testing/verification-levels.md +16 -0
  93. package/docs/testing/verification-levels.zh-CN.md +13 -1
  94. package/i18n/README.zh-CN.md +13 -7
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +3 -2
  97. package/skills/yui-operator/SKILL.md +13 -2
  98. package/skills/yui-reviewer/SKILL.md +4 -0
  99. package/skills/yui-runtime/SKILL.md +17 -2
  100. package/skills/yui-runtime/references/publication.md +26 -4
  101. package/skills/yui-runtime/references/recovery.md +24 -0
@@ -19,11 +19,13 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
19
19
  import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
20
20
  import { createManagedWorkspace, isTaskOwnedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
21
21
  import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
22
- import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
22
+ import { acquireProjectMaintenanceLocks, ProjectMaintenanceLockedError, ProjectMaintenanceLockCancelledError } from "./projectMaintenanceLock.js";
23
23
  import { generateTaskWorkspaceIdentity, taskWorkspaceRefSegment, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
24
24
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
25
25
  import { captureTaskBaseProvenance, recordTaskBaseProvenanceEvents } from "./taskBaseFreshness.js";
26
26
  import { managedTaskRoot } from "../storage/homeLayout.js";
27
+ import { inspectWorkspaceCleanup } from "./workspaceCleanupInspection.js";
28
+ import { CleanupInspectionError } from "../workspace/cleanupInspection.js";
27
29
  const MAIN_WORKTREE = "main";
28
30
  const LEADER_ROLE = "leader";
29
31
  /**
@@ -62,11 +64,21 @@ export class FileTaskWorkspacePreparer {
62
64
  store;
63
65
  git;
64
66
  now;
65
- constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date()) {
67
+ maintenanceLockOptions;
68
+ constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), maintenanceLockOptions = {}) {
66
69
  this.home = home;
67
70
  this.store = store;
68
71
  this.git = git;
69
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);
70
82
  }
71
83
  #resourceRegistrarValue;
72
84
  #resourceRegistrar() {
@@ -75,7 +87,7 @@ export class FileTaskWorkspacePreparer {
75
87
  #registerWorkspace(workspace) {
76
88
  this.#resourceRegistrar().registerManagedWorkspace(workspace);
77
89
  }
78
- async prepareTaskWorkspace(taskId) {
90
+ async prepareTaskWorkspace(taskId, signal) {
79
91
  // The per-Project maintenance fence makes prepare mutually exclusive with
80
92
  // Project maintenance and Task archive cleanup: a concurrent migration must not switch the
81
93
  // Project catalog to the Home-managed repo while prepare is creating
@@ -87,7 +99,7 @@ export class FileTaskWorkspacePreparer {
87
99
  for (let attempt = 0;; attempt += 1) {
88
100
  try {
89
101
  const task = requireTask(this.store, taskId);
90
- const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
102
+ const { release, current } = await this.#acquireTaskProjectMaintenanceLocks(task, signal);
91
103
  try {
92
104
  return await this.#prepareTaskWorkspaceLocked(current.id, false);
93
105
  }
@@ -116,11 +128,14 @@ export class FileTaskWorkspacePreparer {
116
128
  * later launch failure can never present itself as "activation never
117
129
  * happened".
118
130
  */
119
- async activateTaskWorkspace(taskId, environment = {}) {
131
+ async activateTaskWorkspace(taskId, environment = {}, signal) {
120
132
  for (let attempt = 0;; attempt += 1) {
121
133
  let attemptedRequest;
122
134
  try {
123
135
  const task = requireTask(this.store, taskId);
136
+ if (signal?.aborted) {
137
+ throw new ProjectMaintenanceLockCancelledError(task.projectBindings[0]?.projectId ?? task.id, signal.reason);
138
+ }
124
139
  const caller = taskLocalActor(this.store, environment, task.id);
125
140
  const actor = task.activationRequest?.operation.actorId === `task:${task.id}/role:leader`
126
141
  ? "leader" : caller;
@@ -148,12 +163,14 @@ export class FileTaskWorkspacePreparer {
148
163
  if (admission.disposition === "ready")
149
164
  attemptedRequest = admission.request;
150
165
  validateDraftTaskForActivation(this.store, task);
151
- const adopted = await this.#adoptActivationEnvironment(task);
152
- return adopted.workspaceFree
153
- ? this.#activateWorkspaceFreeTask(task.id, adopted, actor)
154
- : await this.#prepareActivatedTaskWorkspace(task, adopted, actor);
166
+ return await this.#prepareActivatedTaskWorkspace(task, actor, environment, signal);
155
167
  }
156
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;
157
174
  if (error instanceof StorageConflictError
158
175
  && attempt < TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
159
176
  continue;
@@ -170,10 +187,28 @@ export class FileTaskWorkspacePreparer {
170
187
  }
171
188
  }
172
189
  /** Prepares the managed workspace of a Task being activated, under its fences. */
173
- async #prepareActivatedTaskWorkspace(task, environment, actor) {
174
- const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
190
+ async #prepareActivatedTaskWorkspace(task, actor, callerEnvironment, signal) {
191
+ const { release, current } = await this.#acquireTaskProjectMaintenanceLocks(task, signal);
175
192
  try {
176
- return await this.#prepareTaskWorkspaceLocked(current.id, true, environment, actor);
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);
177
212
  }
178
213
  finally {
179
214
  release();
@@ -330,12 +365,19 @@ export class FileTaskWorkspacePreparer {
330
365
  * caller re-reads the Task and re-acquires the correct fence set. The
331
366
  * caller owns `release` and MUST call it on every exit path.
332
367
  */
333
- #acquireTaskProjectMaintenanceLocks(task) {
368
+ async #acquireTaskProjectMaintenanceLocks(task, signal) {
334
369
  const projectIds = task.projectBindings.map(({ projectId }) => projectId);
370
+ const waitSignal = signal ?? this.maintenanceLockOptions.signal;
335
371
  const release = projectIds.length === 0
336
372
  ? () => { }
337
- : acquireProjectMaintenanceLocks(this.home, projectIds);
373
+ : await acquireProjectMaintenanceLocks(this.home, projectIds, {
374
+ ...this.maintenanceLockOptions,
375
+ signal: waitSignal
376
+ });
338
377
  try {
378
+ if (waitSignal?.aborted) {
379
+ throw new ProjectMaintenanceLockCancelledError(projectIds[0] ?? task.id, waitSignal.reason);
380
+ }
339
381
  const current = requireTask(this.store, task.id);
340
382
  const currentIds = current.projectBindings.map(({ projectId }) => projectId).sort();
341
383
  const lockedIds = [...projectIds].sort();
@@ -358,13 +400,16 @@ export class FileTaskWorkspacePreparer {
358
400
  * catalog in that gap. The caller owns `release` and must call it on every
359
401
  * exit path.
360
402
  */
361
- acquireTaskProjectMaintenanceLocks(taskId) {
362
- return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId));
403
+ acquireTaskProjectMaintenanceLocks(taskId, signal) {
404
+ return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId), signal);
363
405
  }
364
406
  async #prepareTaskWorkspaceLocked(taskId, activate,
365
407
  /** Environment and request identity the activation adopted, when one exists. */
366
408
  activationEnvironment = { workspaceFree: false }, actor = "user") {
367
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
+ }
368
413
  if (activate && task.status !== "draft") {
369
414
  throw new Error(`Only a Draft Task can atomically adopt a workspace: ${task.id}/${task.status}.`);
370
415
  }
@@ -910,15 +955,17 @@ export class FileTaskWorkspacePreparer {
910
955
  return projects;
911
956
  }
912
957
  async prepareWorkItemWorkspace(taskId, workItemId) {
913
- const item = requireWorkItem(this.store, taskId, workItemId);
958
+ let item = requireWorkItem(this.store, taskId, workItemId);
914
959
  const task = requireTask(this.store, item.taskId);
915
960
  assertWorkItemWorkspaceEligible(this.store, task, item);
916
961
  // Hold the per-Project maintenance fence across the ensure-main-prepared
917
962
  // and the WorkItem worktree creation, so concurrent Project maintenance
918
963
  // cannot switch the Project catalog between the two. The under-lock Task
919
964
  // snapshot drives every Project read and Git effect below.
920
- const { release, current: lockedTask } = this.#acquireTaskProjectMaintenanceLocks(task);
965
+ const { release, current: lockedTask } = await this.#acquireTaskProjectMaintenanceLocks(task);
921
966
  try {
967
+ item = requireWorkItem(this.store, taskId, workItemId);
968
+ assertWorkItemWorkspaceEligible(this.store, lockedTask, item);
922
969
  const lockedProjectIds = lockedTask.projectBindings.map(({ projectId }) => projectId);
923
970
  await this.#prepareTaskWorkspaceWithRetries(lockedTask.id, lockedProjectIds);
924
971
  // prepareTaskWorkspace may have just minted and persisted the workspace
@@ -1099,9 +1146,12 @@ export class FileTaskWorkspacePreparer {
1099
1146
  // already holds (one locked boundary across preparation and adoption);
1100
1147
  // otherwise the fence is acquired here.
1101
1148
  const { release, current: lockedTask } = heldFence === undefined
1102
- ? this.#acquireTaskProjectMaintenanceLocks(task)
1149
+ ? await this.#acquireTaskProjectMaintenanceLocks(task)
1103
1150
  : { release: () => { }, current: heldFence.current };
1104
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
+ }
1105
1155
  const lineage = executionLaneLineage(this.store, lockedTask, executionGroupId, executionLaneId, hint);
1106
1156
  const item = lineage.purpose === "execution"
1107
1157
  ? this.store.getWorkItem(taskId, lineage.workItemId)
@@ -1269,6 +1319,7 @@ export class FileTaskWorkspacePreparer {
1269
1319
  && owner.executionLaneId === executionLaneId));
1270
1320
  if (workspace === undefined)
1271
1321
  return "missing";
1322
+ await this.#assertWorkspaceCleanup(workspace, "abandoned");
1272
1323
  const state = await this.#inspectEntries(task.id, this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
1273
1324
  if (state === "dirty")
1274
1325
  return "dirty";
@@ -1404,8 +1455,11 @@ export class FileTaskWorkspacePreparer {
1404
1455
  * never used as a ChangeSet capture source. */
1405
1456
  async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
1406
1457
  const taskSnapshot = requireTask(this.store, taskId);
1407
- const { release, current: task } = this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
1458
+ const { release, current: task } = await this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
1408
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
+ }
1409
1463
  const taskSegment = this.#taskSegment(task);
1410
1464
  const round = this.store.getReviewRound(task.id, reviewRoundId);
1411
1465
  if (round === null)
@@ -1878,6 +1932,7 @@ export class FileTaskWorkspacePreparer {
1878
1932
  if (!isDeepStrictEqual(round.workspace, workspace)) {
1879
1933
  throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1880
1934
  }
1935
+ await this.#assertWorkspaceCleanup(workspace, "abandoned");
1881
1936
  assertWorkspaceSessionsRetirable(this.store, task.id, round.reviewerRoleName, this.now());
1882
1937
  for (const lane of round.executionGroup?.lanes ?? []) {
1883
1938
  assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
@@ -1945,6 +2000,7 @@ export class FileTaskWorkspacePreparer {
1945
2000
  throw new Error(`WorkItem has no managed isolated worktree workspace: ${item.id}.`);
1946
2001
  }
1947
2002
  assertWorkItemOwnsWorkspace(item, workspace);
2003
+ await this.#assertWorkspaceCleanup(workspace, disposition);
1948
2004
  if (item.assignee !== undefined) {
1949
2005
  assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
1950
2006
  }
@@ -1998,7 +2054,7 @@ export class FileTaskWorkspacePreparer {
1998
2054
  let found = false;
1999
2055
  for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
2000
2056
  try {
2001
- if (!await this.git.isClean(entry.path))
2057
+ if (!await this.git.inspectClean(entry.path))
2002
2058
  return "dirty";
2003
2059
  found = true;
2004
2060
  }
@@ -2010,7 +2066,7 @@ export class FileTaskWorkspacePreparer {
2010
2066
  }
2011
2067
  return found ? "clean" : "missing";
2012
2068
  }
2013
- async cleanupIntegrationWorkspace(taskId, integrationId) {
2069
+ async cleanupIntegrationWorkspace(taskId, integrationId, options = {}) {
2014
2070
  const task = requireTask(this.store, taskId);
2015
2071
  const attempt = this.store.getIntegrationAttempt(task.id, integrationId);
2016
2072
  if (attempt === null) {
@@ -2023,6 +2079,11 @@ export class FileTaskWorkspacePreparer {
2023
2079
  throw new Error(`IntegrationAttempt must be terminal before cleanup: ${attempt.id}.`);
2024
2080
  }
2025
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
+ }
2026
2087
  const project = requireProject(this.store, attempt.projectId);
2027
2088
  // The recorded entry pins the exact on-disk worktree path; reconstruct the
2028
2089
  // owner root from it so removal targets the real Git worktree regardless of
@@ -2042,7 +2103,7 @@ export class FileTaskWorkspacePreparer {
2042
2103
  directory,
2043
2104
  taskSegment: this.#taskSegment(task),
2044
2105
  integrationId: attempt.id,
2045
- discardChanges: task.status !== "archived" && attempt.status === "failed"
2106
+ discardChanges: options.preserveChanges !== true && task.status !== "archived" && attempt.status === "failed"
2046
2107
  });
2047
2108
  if (result !== "dirty") {
2048
2109
  for (const entry of workspace?.entries ?? []) {
@@ -2069,7 +2130,7 @@ export class FileTaskWorkspacePreparer {
2069
2130
  }
2070
2131
  return this.#inspectEntries(task.id, this.#taskSegment(task), MAIN_WORKTREE, main.entries);
2071
2132
  }
2072
- async cleanupTaskForArchive(taskId) {
2133
+ async cleanupTaskForArchive(taskId, disposition = "abandoned") {
2073
2134
  const task = requireTask(this.store, taskId);
2074
2135
  assertTaskArchiveState(task, task);
2075
2136
  const isolated = this.store.listManagedWorkspaces(task.id)
@@ -2084,6 +2145,7 @@ export class FileTaskWorkspacePreparer {
2084
2145
  }
2085
2146
  const main = this.store.getTaskWorkspace(task.id);
2086
2147
  if (main !== null) {
2148
+ await this.#assertWorkspaceCleanup(main, disposition);
2087
2149
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2088
2150
  if (await this.#inspectEntries(task.id, this.#taskSegment(task), MAIN_WORKTREE, main.entries) === "dirty") {
2089
2151
  return {
@@ -0,0 +1,187 @@
1
+ import { dirname, join, relative, isAbsolute } from "node:path";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
4
+ import { WorkItemChangeSetManager } from "../workspace/workItemChangeSetManager.js";
5
+ import { cleanupCheckFromError } from "../workspace/cleanupInspection.js";
6
+ import { projectTaskRemoteDeliveryFromStore } from "../commands/taskRemoteDeliveryCommand.js";
7
+ import { taskWorkspaceRefSegment } from "./taskWorkspaceIdentity.js";
8
+ import { integrationWorktreeIdentity, worktreeIdentity } from "./gitWorkspace.js";
9
+ /** Current owner facts + the same Git inspection used immediately before removal.
10
+ * No preparation, locks, Git refresh, runtime stop, DB writes or repair.
11
+ */
12
+ export async function inspectWorkspaceCleanup(store, git, workspace, disposition, forceArchive = false) {
13
+ const owner = workspace.owner;
14
+ const task = store.getTask(owner.taskId);
15
+ if (task === null)
16
+ throw new Error(`Task not found: ${owner.taskId}.`);
17
+ const resource = managedWorkspaceKey(owner);
18
+ const sources = [resource];
19
+ const actions = workspaceCleanupActions(workspace);
20
+ const checks = [];
21
+ const add = (reason, detail, expected, observed) => checks.push({ resource, reason, status: "blocked", detail, expected, observed, sources, actions });
22
+ if (!isDeepStrictEqual(store.getManagedWorkspace(owner), workspace)) {
23
+ add("workspace-identity-mismatch", "Current workspace ownership differs from the inspected record.", "same managed workspace", "changed or missing");
24
+ return checks;
25
+ }
26
+ if (task.workspaceIdentity === undefined && workspace.entries.some(e => e.access === "write")) {
27
+ add("workspace-metadata-missing", "Task has no durable workspace identity; do not infer one from paths.", "recorded Task workspace identity", null);
28
+ return checks;
29
+ }
30
+ let roleName = managedWorktreeName(owner);
31
+ switch (owner.type) {
32
+ case "work-item": {
33
+ const item = store.getWorkItem(task.id, owner.workItemId);
34
+ if (item === null || !["accepted", "retired"].includes(item.status)) {
35
+ add("owner-unsettled", "WorkItem must be terminal before cleanup.", ["accepted", "retired"], item?.status ?? null);
36
+ }
37
+ if (item?.workspaceDisposition !== undefined && item.workspaceDisposition !== disposition) {
38
+ add("workspace-disposition-mismatch", "WorkItem cleanup disposition differs from the recorded decision.", item.workspaceDisposition, disposition);
39
+ }
40
+ if (item?.status === "accepted" && disposition === "integrated") {
41
+ const inspection = await new WorkItemChangeSetManager(store).inspectIntegrated(task.id, item.id);
42
+ checks.push(...inspection.checks);
43
+ }
44
+ break;
45
+ }
46
+ case "review-round": {
47
+ const round = store.getReviewRound(task.id, owner.reviewRoundId);
48
+ if (round === null || !["completed", "failed"].includes(round.status)) {
49
+ add("owner-unsettled", "ReviewRound must be terminal before cleanup.", ["completed", "failed"], round?.status ?? null);
50
+ }
51
+ if (round?.workspace === undefined || !isDeepStrictEqual(round.workspace, workspace)) {
52
+ add("workspace-metadata-mismatch", "ReviewRound and managed workspace records diverge.", "same workspace in ReviewRound and registry", "different or missing mirror");
53
+ }
54
+ if (round?.scope === "task")
55
+ roleName = `reviewer-${round.reviewerRoleName}`;
56
+ break;
57
+ }
58
+ case "execution-lane": {
59
+ const group = owner.purpose === "execution"
60
+ ? store.getWorkItem(task.id, owner.workItemId)?.executionGroups.find(g => g.id === owner.executionGroupId)
61
+ : store.getReviewRound(task.id, owner.reviewRoundId)?.executionGroup;
62
+ const lane = group?.id === owner.executionGroupId
63
+ ? group.lanes.find(l => l.id === owner.executionLaneId) : undefined;
64
+ if (lane === undefined || lane.disposition === "open") {
65
+ add("owner-unsettled", "Execution Lane is not terminally resolved.", "settled lane", lane?.disposition ?? null);
66
+ }
67
+ break;
68
+ }
69
+ case "integration-attempt": {
70
+ const attempt = store.getIntegrationAttempt(task.id, owner.integrationAttemptId);
71
+ if (attempt === null || ["running", "blocked", "conflicted", "validating"].includes(attempt.status)) {
72
+ add("owner-unsettled", "IntegrationAttempt must be terminal before cleanup.", "terminal attempt", attempt?.status ?? null);
73
+ }
74
+ break;
75
+ }
76
+ case "task":
77
+ if (!["completed", "cancelled", "archived"].includes(task.status)) {
78
+ add("task-not-terminal", "Task must be completed or retired before archive cleanup.", ["completed", "cancelled", "archived"], task.status);
79
+ }
80
+ break;
81
+ }
82
+ const delivery = owner.type === "task" && (disposition === "integrated" || task.status === "archived" || forceArchive)
83
+ ? await readArchiveDelivery(store, git, task, forceArchive) : undefined;
84
+ if (delivery !== undefined && (!delivery.allMerged || !delivery.allVerified)) {
85
+ add("delivery-coverage", "Task main has commits without verified remote coverage; cleanup does not establish delivery.", { allMerged: true, allVerified: true }, { allMerged: delivery.allMerged, allVerified: delivery.allVerified });
86
+ }
87
+ for (const entry of workspace.entries.filter(e => e.access === "write")) {
88
+ const entryResource = `${resource}/${entry.projectId}`;
89
+ const entryChecks = [];
90
+ const check = (reason, detail, expected, observed) => entryChecks.push({ resource: entryResource, reason, status: "blocked", detail, expected, observed, sources, actions });
91
+ // Integration's durable root is its single Project worktree (not the
92
+ // multi-Project owner container used by the other workspace owners).
93
+ const container = owner.type === "integration-attempt" ? dirname(workspace.root) : workspace.root;
94
+ const expectedPath = join(container, entry.directory);
95
+ if (entry.path !== expectedPath) {
96
+ check("workspace-path-mismatch", "Recorded path is not the current owner-root/Project path; relocation is not proven.", { ownerRelativePath: entry.directory }, { ownerRelativePath: safeRelativePath(container, entry.path) });
97
+ checks.push(...entryChecks);
98
+ continue; // Never inspect a foreign/mismatched path.
99
+ }
100
+ const taskSegment = taskWorkspaceRefSegment(task);
101
+ const expectedBranch = owner.type === "integration-attempt"
102
+ ? integrationWorktreeIdentity(taskSegment, owner.integrationAttemptId).branch
103
+ : worktreeIdentity(taskSegment, roleName).branch;
104
+ if (entry.branch !== expectedBranch) {
105
+ check("workspace-identity-mismatch", "Recorded branch does not match the exact owner.", expectedBranch, entry.branch);
106
+ checks.push(...entryChecks);
107
+ continue;
108
+ }
109
+ try {
110
+ const main = store.getTaskWorkspace(task.id)?.entries.find(e => e.projectId === entry.projectId);
111
+ if (main === undefined && owner.type !== "task") {
112
+ check("workspace-metadata-missing", "Task main Git repository reference is unavailable.", "Task main Project entry", null);
113
+ }
114
+ else {
115
+ const state = owner.type === "task"
116
+ ? await git.inspectTaskClone({ path: entry.path, container: workspace.root, directory: entry.directory,
117
+ taskSegment, branch: entry.branch })
118
+ : await git.inspectWorktree({ repositoryPath: main.path, container, directory: entry.directory,
119
+ taskSegment, roleName, expectedBranch });
120
+ if (state === "dirty")
121
+ check("dirty-worktree", "Uncommitted or untracked changes must be preserved.", "clean", "dirty");
122
+ if (owner.type === "task" && state !== "missing" && delivery !== undefined) {
123
+ const covered = delivery.projects.find(p => p.projectId === entry.projectId);
124
+ const expected = covered?.expectedLocalCommit;
125
+ const observed = (await git.inspect(entry.path, "HEAD")).baseCommit;
126
+ if (expected != null && expected !== observed && covered?.deliveryLocalCommit !== observed) {
127
+ check("head-mismatch", "Task main HEAD differs from the accepted head and its covered publication candidate.", { acceptedCommit: expected, deliveryLocalCommit: covered?.deliveryLocalCommit ?? null }, observed);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ catch (error) {
133
+ entryChecks.push(...cleanupCheckFromError(error, entryResource, sources, actions));
134
+ }
135
+ checks.push(...entryChecks);
136
+ }
137
+ return checks;
138
+ }
139
+ /** Reuse Publication's projection, supplying live heads only where its existing
140
+ * contract asks for them (active/cancelled). Completed/archived heads stay frozen.
141
+ * Never prepare a workspace or substitute an old binding's commit for a failed read.
142
+ */
143
+ export async function readArchiveDelivery(store, git, task, forceArchive = false) {
144
+ const projects = [];
145
+ // Force admission preserves its existing store-only evidence; it never
146
+ // freezes new delivery heads merely because an inspection observed them.
147
+ if (!forceArchive && (task.status === "active" || task.status === "cancelled") && task.workspaceIdentity !== undefined) {
148
+ const main = store.getTaskWorkspace(task.id);
149
+ if (main?.owner.type === "task" && main.owner.taskId === task.id) {
150
+ const branch = worktreeIdentity(taskWorkspaceRefSegment(task), "main").branch;
151
+ for (const entry of main.entries.filter(e => e.access === "write")) {
152
+ if (entry.path !== join(main.root, entry.directory) || entry.branch !== branch)
153
+ continue;
154
+ try {
155
+ const head = await git.inspect(entry.path, "HEAD");
156
+ if (head.root === entry.path && await git.headRef(entry.path) === branch) {
157
+ projects.push({ projectId: entry.projectId, commit: head.baseCommit });
158
+ }
159
+ }
160
+ catch { /* Failed reads remain unavailable in the existing projection. */ }
161
+ }
162
+ }
163
+ }
164
+ return projectTaskRemoteDeliveryFromStore(store, task, { projects });
165
+ }
166
+ export function workspaceCleanupActions(workspace) {
167
+ const owner = workspace.owner;
168
+ switch (owner.type) {
169
+ case "task": return [`yui task remote-delivery ${owner.taskId}`, `yui task show ${owner.taskId}`];
170
+ case "work-item": return [`yui task work show ${owner.taskId}/${owner.workItemId}`,
171
+ `yui task work cleanup ${owner.taskId}/${owner.workItemId} --integrated|--abandon`];
172
+ case "review-round": return [`yui task work review show ${owner.taskId}/${owner.reviewRoundId}`,
173
+ `yui task work review cleanup ${owner.taskId}/${owner.reviewRoundId}`];
174
+ case "integration-attempt": return [`yui task integration show ${owner.taskId}/${owner.integrationAttemptId}`,
175
+ `yui task integration cleanup ${owner.taskId}/${owner.integrationAttemptId}`];
176
+ case "execution-lane": return owner.purpose === "execution"
177
+ ? [`yui task work show ${owner.taskId}/${owner.workItemId}`,
178
+ `yui task work cleanup ${owner.taskId}/${owner.workItemId} --integrated|--abandon`]
179
+ : [`yui task work review show ${owner.taskId}/${owner.reviewRoundId}`,
180
+ `yui task work review cleanup ${owner.taskId}/${owner.reviewRoundId}`];
181
+ }
182
+ }
183
+ /** Only expose paths inside this already-authorized owner, never foreign paths. */
184
+ export function safeRelativePath(root, path) {
185
+ const value = relative(root, path);
186
+ return value === ".." || value.startsWith("../") || isAbsolute(value) ? "[outside owner root]" : value || ".";
187
+ }
@@ -21,7 +21,7 @@ export const AGENT_ERROR_CATEGORIES = Object.freeze([
21
21
  ]);
22
22
  export function standardAgentError(input) {
23
23
  const classification = input.classification ?? UNKNOWN_AGENT_ERROR_CLASSIFICATION;
24
- const retryAfterMs = input.retryAfterMs;
24
+ const retryAfterMs = input.retryAfterMs ?? classification.retryAfterMs;
25
25
  if (retryAfterMs !== undefined
26
26
  && (!Number.isSafeInteger(retryAfterMs) || retryAfterMs < 0)) {
27
27
  throw new Error("Agent error retryAfterMs must be a non-negative safe integer.");
@@ -42,7 +42,8 @@ export function standardAgentError(input) {
42
42
  sessionDisposition: input.sessionDisposition
43
43
  ?? classification.sessionDisposition
44
44
  ?? "unknown",
45
- ...(retryAfterMs === undefined ? {} : { retryAfterMs })
45
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
46
+ ...(classification.retryable === undefined ? {} : { retryable: classification.retryable })
46
47
  });
47
48
  }
48
49
  export const UNKNOWN_AGENT_ERROR_CLASSIFICATION = Object.freeze({
@@ -177,7 +178,8 @@ export function isStandardAgentError(value) {
177
178
  && ["accepted", "not-accepted", "unknown"].includes(error.inputDisposition ?? "")
178
179
  && ["recoverable", "unrecoverable", "unknown"].includes(error.sessionDisposition ?? "")
179
180
  && (error.retryAfterMs === undefined
180
- || (Number.isSafeInteger(error.retryAfterMs) && error.retryAfterMs >= 0));
181
+ || (Number.isSafeInteger(error.retryAfterMs) && error.retryAfterMs >= 0))
182
+ && (error.retryable === undefined || typeof error.retryable === "boolean");
181
183
  }
182
184
  /**
183
185
  * Upper bound for one persisted raw payload. A Provider stack trace or a
@@ -18,6 +18,7 @@ import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox }
18
18
  import { AGENT_HOST_CLIENT_EXIT_GRACE_MS, AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS, ENDPOINT_DRAIN_TIMEOUT_MS, PROVIDER_ACCEPT_TIMEOUT_MS } from "./runtimeDeadlines.js";
19
19
  import { providerDeliveryFailure, providerDeliveryFailureFrom, redactAgentErrorText, serializeAgentErrorRaw } from "./agentError.js";
20
20
  import { runCodexInteractiveHost } from "./codexInteractiveHost.js";
21
+ import { CodexPreSubmissionError } from "./codexAppServerRuntime.js";
21
22
  import { readAgentRunConfigurationObservation, unknownAgentRunConfiguration } from "./agentRunConfiguration.js";
22
23
  import { createSessionOwnerIdentity, readLinuxProcessIdentity } from "./sessionOwnerIdentity.js";
23
24
  import { yuiTmuxServerName, yuiTmuxSessionName } from "../tmux/tmuxManager.js";
@@ -767,13 +768,25 @@ export async function runAgentHost(input) {
767
768
  attemptId: request.run.attemptId,
768
769
  ...authorityFields()
769
770
  }));
770
- const durableRun = hostRunControlParams(sessionPayload, session.nativeSessionId, request.authority, request.run.attemptId, request.runId);
771
+ const durableRun = { ...hostRunControlParams(sessionPayload, session.nativeSessionId, request.authority, request.run.attemptId, request.runId), boundedText: request.run.boundedText,
772
+ retrySupport: session.adapterId === "codex" ? "codex-failed-turn-v1" : "unsupported" };
773
+ let recoveryInput;
771
774
  // Registration precedes the Provider write, so its failure modes are not
772
775
  // the Provider's. A definite failure means the Provider never saw this
773
776
  // input and the occupancy must be released; anything unconfirmed leaves
774
777
  // durable state ambiguous and keeps it held.
775
778
  try {
776
- await beginDurableProviderTurn(input.home, durableRun);
779
+ const admitted = await beginDurableProviderTurn(input.home, durableRun);
780
+ if (admitted !== null && typeof admitted === "object" && !Array.isArray(admitted)) {
781
+ const retry = admitted.retryInput;
782
+ if (retry !== null && typeof retry === "object" && !Array.isArray(retry)
783
+ && typeof retry.text === "string")
784
+ recoveryInput = {
785
+ text: retry.text,
786
+ ...(typeof retry.expectedFailedNativeTurnId === "string"
787
+ ? { expectedFailedNativeTurnId: retry.expectedFailedNativeTurnId } : {})
788
+ };
789
+ }
777
790
  }
778
791
  catch (error) {
779
792
  // Registration ends in one of three states and they are not
@@ -831,7 +844,11 @@ export async function runAgentHost(input) {
831
844
  // authority and Session identity before this native write.
832
845
  const receipt = endpointReceipt(await session.submit({
833
846
  ...request.run,
834
- boundedText: withSessionContextPointer(request.run.boundedText, activeRunPayload.environment),
847
+ boundedText: withSessionContextPointer(recoveryInput?.text ?? request.run.boundedText, activeRunPayload.environment),
848
+ ...(recoveryInput === undefined ? {} : { requireQuiescent: true }),
849
+ ...(recoveryInput?.expectedFailedNativeTurnId === undefined ? {} : {
850
+ expectedFailedNativeTurnId: recoveryInput.expectedFailedNativeTurnId
851
+ }),
835
852
  inputRef: request.runId ?? request.run.attemptId
836
853
  }), request.run.attemptId);
837
854
  transportAccepted = true;
@@ -1852,7 +1869,7 @@ function hostRunControlParams(payload, nativeSessionId, authority, attemptId, ru
1852
1869
  }
1853
1870
  async function beginDurableProviderTurn(home, durableRun) {
1854
1871
  try {
1855
- await callControllerIdempotently(home, "runtime.provider-turn-begin", durableRun);
1872
+ return await callControllerIdempotently(home, "runtime.provider-turn-begin", durableRun);
1856
1873
  }
1857
1874
  catch (error) {
1858
1875
  if (error instanceof ControllerClientError && error.code === "SESSION_BUSY") {
@@ -1860,7 +1877,7 @@ async function beginDurableProviderTurn(home, durableRun) {
1860
1877
  }
1861
1878
  if (!(error instanceof ControllerAcknowledgementUnknownError))
1862
1879
  throw error;
1863
- await resolveProviderTurnSubmission(home, durableRun, new Error(`Provider Turn intent acknowledgement failed before Provider write: ${errorText(error)}`, { cause: error }));
1880
+ await resolveProviderTurnSubmission(home, durableRun, new Error(`Provider Turn intent acknowledgement failed before Provider write: ${errorText(error)}`, { cause: error }), true);
1864
1881
  // The resolve is fenced on this exact attempt id, so its success proves
1865
1882
  // the registration did commit and is now settled with nothing written to
1866
1883
  // the Provider. That is a known outcome; reporting it as unknown would
@@ -1871,7 +1888,7 @@ async function beginDurableProviderTurn(home, durableRun) {
1871
1888
  }
1872
1889
  async function callControllerIdempotently(home, method, request) {
1873
1890
  try {
1874
- await callAgentController(home, method, request);
1891
+ return await callAgentController(home, method, request);
1875
1892
  }
1876
1893
  catch (error) {
1877
1894
  if (!(error instanceof ControllerClientError)
@@ -1882,7 +1899,7 @@ async function callControllerIdempotently(home, method, request) {
1882
1899
  // These methods carry exact attempt, launch, and authority fences. A
1883
1900
  // bounded replay confirms a commit whose acknowledgement may have been lost.
1884
1901
  try {
1885
- await callAgentController(home, method, request);
1902
+ return await callAgentController(home, method, request);
1886
1903
  }
1887
1904
  catch (replayError) {
1888
1905
  if (firstCallMayHaveApplied || controllerCallMayHaveApplied(replayError)) {
@@ -1903,7 +1920,7 @@ class ControllerAcknowledgementUnknownError extends Error {
1903
1920
  class ProviderTurnRegistrationSettledError extends Error {
1904
1921
  name = "ProviderTurnRegistrationSettledError";
1905
1922
  }
1906
- async function resolveProviderTurnSubmission(home, durableRun, error) {
1923
+ async function resolveProviderTurnSubmission(home, durableRun, error, noProviderWrite = false) {
1907
1924
  const attemptId = durableRun.attemptId;
1908
1925
  if (typeof attemptId !== "string") {
1909
1926
  throw new Error("Provider Turn resolution has no attempt id.");
@@ -1915,6 +1932,7 @@ async function resolveProviderTurnSubmission(home, durableRun, error) {
1915
1932
  : error instanceof ProviderTurnBusyError ? "deferred" : "rejected",
1916
1933
  reason: errorText(error),
1917
1934
  raw: serializeAgentErrorRaw(error),
1935
+ ...(noProviderWrite || error instanceof CodexPreSubmissionError ? { providerWrite: "not-issued" } : {}),
1918
1936
  observedAt: new Date().toISOString()
1919
1937
  };
1920
1938
  try {
@@ -1935,8 +1953,7 @@ async function resolveProviderTurnSubmission(home, durableRun, error) {
1935
1953
  */
1936
1954
  async function callAgentController(home, method, params) {
1937
1955
  try {
1938
- await callController(home, method, params);
1939
- return;
1956
+ return await callController(home, method, params);
1940
1957
  }
1941
1958
  catch (error) {
1942
1959
  if (!isControllerUnavailable(error) || !isForeignHandoverLockHeld(home)) {
@@ -1948,7 +1965,7 @@ async function callAgentController(home, method, params) {
1948
1965
  await delay(50);
1949
1966
  // The pinned Host can rediscover a replacement, but must never start a
1950
1967
  // Controller using its own old implementation or open the Home's database.
1951
- await callController(home, method, params);
1968
+ return await callController(home, method, params);
1952
1969
  }
1953
1970
  function isControllerUnavailable(error) {
1954
1971
  return error instanceof ControllerClientError