@zq-silk/yui 2.1.0 → 2.3.0

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 (135) hide show
  1. package/README.md +8 -2
  2. package/dist/cli/commandCatalog.js +32 -6
  3. package/dist/cli/commandDiscovery.js +4 -1
  4. package/dist/cli/interactionPolicy.js +4 -0
  5. package/dist/cli.js +23 -3078
  6. package/dist/commands/taskCommands.js +84 -28
  7. package/dist/commands/taskIntegrationCommands.js +8 -17
  8. package/dist/commands/taskUpstreamCommands.js +3 -1
  9. package/dist/context/runContextPack.js +132 -5
  10. package/dist/context/sourceRunContext.js +4 -2
  11. package/dist/context/taskContext.js +6 -1
  12. package/dist/controlPlaneCli.js +3099 -0
  13. package/dist/controller/clientRuntime.js +3 -2
  14. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  15. package/dist/controller/jobControl.js +21 -0
  16. package/dist/executor/agentExecutor.js +1 -1
  17. package/dist/executor/effectiveLaunch.js +18 -5
  18. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  19. package/dist/integration/gitIntegrationService.js +58 -82
  20. package/dist/job/jobCleanupInspection.js +67 -0
  21. package/dist/message/messageContinuation.js +7 -2
  22. package/dist/nativeAgent/agent.js +176 -74
  23. package/dist/nativeAgent/cliDemo.js +37 -0
  24. package/dist/nativeAgent/codingTools.js +11 -0
  25. package/dist/nativeAgent/commandTool.js +215 -0
  26. package/dist/nativeAgent/compactionDemo.js +158 -0
  27. package/dist/nativeAgent/composition.js +71 -0
  28. package/dist/nativeAgent/context/budget.js +44 -0
  29. package/dist/nativeAgent/context/index.js +339 -0
  30. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  31. package/dist/nativeAgent/demo.js +12 -1
  32. package/dist/nativeAgent/evaluation/cases.js +38 -0
  33. package/dist/nativeAgent/evaluation/checks.js +91 -0
  34. package/dist/nativeAgent/evaluation/demo.js +19 -0
  35. package/dist/nativeAgent/evaluation/files.js +54 -0
  36. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  37. package/dist/nativeAgent/evaluation/index.js +239 -0
  38. package/dist/nativeAgent/executionOwner.js +209 -0
  39. package/dist/nativeAgent/filePatterns.js +170 -0
  40. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  41. package/dist/nativeAgent/index.js +13 -0
  42. package/dist/nativeAgent/interaction/cli.js +358 -0
  43. package/dist/nativeAgent/interaction/contracts.js +1 -0
  44. package/dist/nativeAgent/interaction/index.js +3 -0
  45. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  46. package/dist/nativeAgent/interaction/renderer.js +34 -0
  47. package/dist/nativeAgent/localSafety.js +258 -0
  48. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  49. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  50. package/dist/nativeAgent/model/errors.js +47 -0
  51. package/dist/nativeAgent/model/gateway.js +423 -0
  52. package/dist/nativeAgent/model/index.js +7 -0
  53. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  54. package/dist/nativeAgent/model/protocols.js +19 -0
  55. package/dist/nativeAgent/model/responses.js +263 -0
  56. package/dist/nativeAgent/model/types.js +1 -0
  57. package/dist/nativeAgent/model/wire.js +73 -0
  58. package/dist/nativeAgent/observability/index.js +220 -0
  59. package/dist/nativeAgent/product/catalog.js +82 -0
  60. package/dist/nativeAgent/product/config.js +295 -0
  61. package/dist/nativeAgent/product/facts.js +30 -0
  62. package/dist/nativeAgent/product/index.js +62 -0
  63. package/dist/nativeAgent/product/location.js +44 -0
  64. package/dist/nativeAgent/product/runtime.js +276 -0
  65. package/dist/nativeAgent/product/storage.js +49 -0
  66. package/dist/nativeAgent/product/tools.js +47 -0
  67. package/dist/nativeAgent/product/transport.js +54 -0
  68. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  69. package/dist/nativeAgent/searchTools.js +305 -0
  70. package/dist/nativeAgent/session/backends.js +293 -0
  71. package/dist/nativeAgent/session/catalog.js +97 -0
  72. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  73. package/dist/nativeAgent/session/contracts.js +1 -0
  74. package/dist/nativeAgent/session/format.js +269 -0
  75. package/dist/nativeAgent/session/index.js +5 -0
  76. package/dist/nativeAgent/session/location.js +36 -0
  77. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  78. package/dist/nativeAgent/session/store.js +248 -0
  79. package/dist/nativeAgent/textTools.js +270 -133
  80. package/dist/nativeAgent/toolManager/executor.js +290 -0
  81. package/dist/nativeAgent/toolManager/index.js +4 -0
  82. package/dist/nativeAgent/validation.js +2 -2
  83. package/dist/repository/gitWorkspace.js +41 -12
  84. package/dist/repository/taskWorkspaceCoordinator.js +72 -145
  85. package/dist/repository/taskWorkspacePreparer.js +39 -43
  86. package/dist/repository/workspaceCleanupInspection.js +88 -7
  87. package/dist/resources/resourceRegistrar.js +16 -10
  88. package/dist/runtime/integrationRuntimeIsolation.js +21 -0
  89. package/dist/runtime/taskRuntimeIsolation.js +91 -21
  90. package/dist/task/archiveDiagnostics.js +26 -8
  91. package/dist/task/archivePreflight.js +26 -9
  92. package/dist/task/leaderArchive.js +9 -15
  93. package/dist/task/taskAuthority.js +56 -0
  94. package/dist/web/assets/client/api.js +8 -0
  95. package/dist/web/assets/client/app.js +74 -9
  96. package/dist/web/assets/client/components.js +95 -36
  97. package/dist/web/assets/client/detail.js +110 -64
  98. package/dist/web/assets/client/dock.js +95 -11
  99. package/dist/web/assets/client/evidence.js +286 -94
  100. package/dist/web/assets/client/i18n.js +182 -62
  101. package/dist/web/assets/client/overview.js +9 -10
  102. package/dist/web/assets/client/records.js +142 -7
  103. package/dist/web/assets/client/sections.js +234 -198
  104. package/dist/web/assets/client/sidebar.js +31 -2
  105. package/dist/web/assets/icons.js +4 -0
  106. package/dist/web/assets/shell.js +2 -1
  107. package/dist/web/assets/styles/components.js +64 -15
  108. package/dist/web/assets/styles/layout.js +11 -4
  109. package/dist/web/assets/styles/responsive.js +16 -6
  110. package/dist/web/assets/styles/tokens.js +1 -1
  111. package/dist/web/assets/styles/views.js +62 -42
  112. package/dist/web/webServer.js +12 -1
  113. package/dist/web/webTaskSurface.js +8 -1
  114. package/docs/agent-result-consumption.md +16 -0
  115. package/docs/agent-result-consumption.zh-CN.md +13 -0
  116. package/docs/examples/agent-offline.mjs +194 -0
  117. package/docs/native-agent.md +283 -0
  118. package/docs/observability/README.md +13 -3
  119. package/docs/observability/README.zh-CN.md +10 -3
  120. package/docs/release-workflow.md +72 -9
  121. package/docs/release-workflow.zh-CN.md +55 -6
  122. package/docs/roles-and-configuration.md +32 -0
  123. package/docs/roles-and-configuration.zh-CN.md +26 -0
  124. package/docs/task-delivery.md +36 -8
  125. package/docs/task-delivery.zh-CN.md +24 -7
  126. package/i18n/README.zh-CN.md +3 -2
  127. package/package.json +1 -1
  128. package/skills/yui-leader/SKILL.md +9 -0
  129. package/skills/yui-leader/references/execution.md +2 -1
  130. package/skills/yui-leader/references/integration.md +20 -0
  131. package/skills/yui-operator/SKILL.md +4 -0
  132. package/skills/yui-reviewer/SKILL.md +8 -0
  133. package/skills/yui-runtime/SKILL.md +11 -0
  134. package/skills/yui-runtime/references/publication.md +20 -4
  135. package/skills/yui-worker/SKILL.md +3 -0
@@ -1,15 +1,19 @@
1
- import { dirname, isAbsolute, join, relative } from "node:path";
1
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
2
  import { isDeepStrictEqual } from "node:util";
3
+ import { lstat, readdir, readlink } from "node:fs/promises";
3
4
  import { projectTaskRemoteDeliveryFromStore } from "../task/remoteDeliveryService.js";
4
5
  import { cleanupCheckFromError } from "../workspace/cleanupInspection.js";
5
6
  import { WorkItemChangeSetManager } from "../workspace/workItemChangeSetManager.js";
6
7
  import { managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
7
8
  import { integrationWorktreeIdentity, worktreeIdentity } from "./gitWorkspace.js";
8
9
  import { taskWorkspaceRefSegment } from "./taskWorkspaceIdentity.js";
10
+ import { jobCleanupBlocker } from "../job/jobCleanupInspection.js";
11
+ import { defaultIntegrationRuntimeIsolation } from "../runtime/integrationRuntimeIsolation.js";
12
+ import { inspectTaskRuntimeCleanup } from "../runtime/taskRuntimeIsolation.js";
9
13
  /** Current owner facts + the same Git inspection used immediately before removal.
10
14
  * No preparation, locks, Git refresh, runtime stop, DB writes or repair.
11
15
  */
12
- export async function inspectWorkspaceCleanup(store, git, workspace, disposition, forceArchive = false) {
16
+ export async function inspectWorkspaceCleanup(store, git, workspace, disposition, forceArchive = false, home, integrationRuntime) {
13
17
  const owner = workspace.owner;
14
18
  const task = store.getTask(owner.taskId);
15
19
  if (task === null)
@@ -23,6 +27,18 @@ export async function inspectWorkspaceCleanup(store, git, workspace, disposition
23
27
  add("workspace-identity-mismatch", "Current workspace ownership differs from the inspected record.", "same managed workspace", "changed or missing");
24
28
  return checks;
25
29
  }
30
+ // Job admission supports these three exact managed owners. Apply the same
31
+ // physical check before every supported owner's cleanup, not only Integration.
32
+ for (const job of store.listDurableJobs(task.id).filter(job => (owner.type === "task" && job.owner.kind === "task")
33
+ || (owner.type === "work-item" && job.owner.kind === "work-item"
34
+ && job.owner.workItemId === owner.workItemId)
35
+ || (owner.type === "integration-attempt" && job.owner.kind === "integration-attempt"
36
+ && job.owner.integrationAttemptId === owner.integrationAttemptId))) {
37
+ const blocker = jobCleanupBlocker(job, home);
38
+ if (blocker !== undefined)
39
+ add("unresolved-execution", blocker, "settled execution", { jobId: job.id, status: job.status });
40
+ }
41
+ checks.push(...await inspectWorkspaceContainer(workspace));
26
42
  if (task.workspaceIdentity === undefined && workspace.entries.some(e => e.access === "write")) {
27
43
  add("workspace-metadata-missing", "Task has no durable workspace identity; do not infer one from paths.", "recorded Task workspace identity", null);
28
44
  return checks;
@@ -68,9 +84,24 @@ export async function inspectWorkspaceCleanup(store, git, workspace, disposition
68
84
  }
69
85
  case "integration-attempt": {
70
86
  const attempt = store.getIntegrationAttempt(task.id, owner.integrationAttemptId);
87
+ const entry = workspace.entries[0];
88
+ if (workspace.entries.length !== 1 || entry?.access !== "write"
89
+ || entry.projectId !== attempt?.projectId || entry.path !== workspace.root) {
90
+ add("workspace-identity-mismatch", "Integration resource does not match its single recorded Project worktree.", "one writable Project at the Integration root", "different or missing entry");
91
+ return checks;
92
+ }
71
93
  if (attempt === null || ["running", "blocked", "conflicted", "validating"].includes(attempt.status)) {
72
94
  add("owner-unsettled", "IntegrationAttempt must be terminal before cleanup.", "terminal attempt", attempt?.status ?? null);
73
95
  }
96
+ try {
97
+ if (home === undefined)
98
+ throw new Error("Runtime Home unavailable.");
99
+ const runtime = integrationRuntime ?? defaultIntegrationRuntimeIsolation(home, store.getHomeIdentity().homeId);
100
+ inspectTaskRuntimeCleanup(runtime.preflight({ workspace, allowExactActive: true }), attempt?.status === "committed" ? "completion" : "failure");
101
+ }
102
+ catch {
103
+ add("runtime-resource-unverified", "Integration runtime ownership or physical quiescence cannot be verified.", "exactly owned inactive runtime and cleanup claims", "unverified");
104
+ }
74
105
  break;
75
106
  }
76
107
  case "task":
@@ -79,9 +110,9 @@ export async function inspectWorkspaceCleanup(store, git, workspace, disposition
79
110
  }
80
111
  break;
81
112
  }
82
- const delivery = owner.type === "task" && (disposition === "integrated" || task.status === "archived" || forceArchive)
113
+ const delivery = owner.type === "task"
83
114
  ? await readArchiveDelivery(store, git, task, forceArchive) : undefined;
84
- if (delivery !== undefined && (!delivery.allMerged || !delivery.allVerified)) {
115
+ if (disposition === "integrated" && delivery !== undefined && (!delivery.allMerged || !delivery.allVerified)) {
85
116
  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
117
  }
87
118
  for (const entry of workspace.entries.filter(e => e.access === "write")) {
@@ -116,14 +147,20 @@ export async function inspectWorkspaceCleanup(store, git, workspace, disposition
116
147
  ? await git.inspectTaskClone({ path: entry.path, container: workspace.root, directory: entry.directory,
117
148
  taskSegment, branch: entry.branch })
118
149
  : await git.inspectWorktree({ repositoryPath: main.path, container, directory: entry.directory,
119
- taskSegment, roleName, expectedBranch });
150
+ taskSegment, roleName,
151
+ ...(owner.type === "integration-attempt" ? { expectedBranch } : {}) });
120
152
  if (state === "dirty")
121
153
  check("dirty-worktree", "Uncommitted or untracked changes must be preserved.", "clean", "dirty");
122
154
  if (owner.type === "task" && state !== "missing" && delivery !== undefined) {
123
155
  const covered = delivery.projects.find(p => p.projectId === entry.projectId);
124
156
  const expected = covered?.expectedLocalCommit;
125
157
  const observed = (await git.inspect(entry.path, "HEAD")).baseCommit;
126
- if (expected != null && expected !== observed && covered?.deliveryLocalCommit !== observed) {
158
+ const baseline = task.projectBindings.find(binding => binding.projectId === entry.projectId)?.baseCommit;
159
+ const unchangedAbandonment = disposition === "abandoned" && baseline !== undefined && observed === baseline;
160
+ if (!unchangedAbandonment && disposition === "abandoned" && (!covered?.merged || !covered.verified)) {
161
+ check("delivery-coverage", "Abandonment does not discard unverified local commits; only the exact unchanged baseline is disposable.", { baseline: baseline ?? null, verifiedDelivery: true }, observed);
162
+ }
163
+ else if (!unchangedAbandonment && expected != null && expected !== observed && covered?.deliveryLocalCommit !== observed) {
127
164
  check("head-mismatch", "Task main HEAD differs from the accepted head and its covered publication candidate.", { acceptedCommit: expected, deliveryLocalCommit: covered?.deliveryLocalCommit ?? null }, observed);
128
165
  }
129
166
  }
@@ -136,6 +173,49 @@ export async function inspectWorkspaceCleanup(store, git, workspace, disposition
136
173
  }
137
174
  return checks;
138
175
  }
176
+ /** Inspect the complete logical container before removing any Project. Files
177
+ * and unrecognized links are evidence, not disposable workspace scaffolding.
178
+ */
179
+ export async function inspectWorkspaceContainer(workspace) {
180
+ if (workspace.owner.type === "integration-attempt")
181
+ return []; // root is the Git worktree itself
182
+ const resource = managedWorkspaceKey(workspace.owner);
183
+ const sources = [resource];
184
+ const actions = workspaceCleanupActions(workspace);
185
+ try {
186
+ const root = await lstat(workspace.root).catch(error => {
187
+ if (error.code === "ENOENT")
188
+ return null;
189
+ throw error;
190
+ });
191
+ if (root === null)
192
+ return [];
193
+ if (!root.isDirectory() || root.isSymbolicLink())
194
+ return [{
195
+ resource, reason: "workspace-identity-mismatch", status: "blocked",
196
+ detail: "Logical workspace container is not an owned directory.",
197
+ expected: "directory", observed: "different filesystem type", sources, actions
198
+ }];
199
+ const unexpected = [];
200
+ for (const entry of await readdir(workspace.root, { withFileTypes: true })) {
201
+ const owned = workspace.entries.find(e => e.directory === entry.name);
202
+ const allowed = owned !== undefined && (owned.access === "write"
203
+ ? entry.isDirectory() && !entry.isSymbolicLink()
204
+ : entry.isSymbolicLink() && resolve(workspace.root, await readlink(join(workspace.root, entry.name))) === resolve(owned.path));
205
+ if (!allowed)
206
+ unexpected.push(entry.name);
207
+ }
208
+ return unexpected.length === 0 ? [] : [{
209
+ resource, reason: "workspace-material-retained", status: "blocked",
210
+ detail: "Logical container contains material outside its registered Project entries; no Project removal was started.",
211
+ expected: "only registered Project directories/links", observed: unexpected,
212
+ sources, actions
213
+ }];
214
+ }
215
+ catch (error) {
216
+ return cleanupCheckFromError(error, resource, sources, actions);
217
+ }
218
+ }
139
219
  /** Reuse Publication's projection, supplying live heads only where its existing
140
220
  * contract asks for them (active/cancelled). Completed/archived heads stay frozen.
141
221
  * Never prepare a workspace or substitute an old binding's commit for a failed read.
@@ -166,7 +246,8 @@ export async function readArchiveDelivery(store, git, task, forceArchive = false
166
246
  export function workspaceCleanupActions(workspace) {
167
247
  const owner = workspace.owner;
168
248
  switch (owner.type) {
169
- case "task": return [`yui task remote-delivery ${owner.taskId}`, `yui task show ${owner.taskId}`];
249
+ case "task": return [`yui task remote-delivery ${owner.taskId}`, `yui task show ${owner.taskId}`,
250
+ `yui task archive-cleanup ${owner.taskId}`];
170
251
  case "work-item": return [`yui task work show ${owner.taskId}/${owner.workItemId}`,
171
252
  `yui task work cleanup ${owner.taskId}/${owner.workItemId} --integrated|--abandon`];
172
253
  case "review-round": return [`yui task work review show ${owner.taskId}/${owner.reviewRoundId}`,
@@ -100,15 +100,17 @@ export class ResourceRegistrar {
100
100
  })]);
101
101
  }
102
102
  markWorkspaceDeleted(workspace) {
103
- try {
104
- this.markPathsDeleted([
105
- workspace.root,
106
- ...workspace.entries.map((entry) => entry.path)
107
- ]);
108
- }
109
- catch {
110
- // Best-effort receipt; workspace deletion must proceed regardless.
111
- }
103
+ // Callers remove the managed owner only after this succeeds. Losing the
104
+ // receipt must retain that exact owner so a missing-path retry can finish.
105
+ this.#markPathsDeleted([
106
+ workspace.root,
107
+ ...workspace.entries.filter(entry => entry.access === "write").map(entry => entry.path)
108
+ ], ownerFromManagedWorkspace(this.#home, workspace.owner));
109
+ }
110
+ markTaskRuntimeDeleted(descriptor) {
111
+ // The validated descriptor and matching registry owner survive deletion.
112
+ // A missing-root retry may settle this receipt, never a different owner.
113
+ this.#markPathsDeleted(Object.values(descriptor.roots), ownerFromManagedWorkspace(this.#home, descriptor.workspace.owner));
112
114
  }
113
115
  markPathsDeleted(paths) {
114
116
  try {
@@ -118,7 +120,7 @@ export class ResourceRegistrar {
118
120
  // Best-effort receipt; path deletion must proceed regardless.
119
121
  }
120
122
  }
121
- #markPathsDeleted(paths) {
123
+ #markPathsDeleted(paths, owner) {
122
124
  const targets = new Set(paths.map((path) => resolve(path)));
123
125
  const store = createResourceRegistryStore(this.#home);
124
126
  try {
@@ -128,6 +130,10 @@ export class ResourceRegistrar {
128
130
  for (const record of Object.values(state.records)) {
129
131
  if (!targets.has(record.path) || record.disposition === "deleted")
130
132
  continue;
133
+ if (owner !== undefined && ["home", "taskId", "workItemId", "reviewRoundId", "integrationAttemptId"]
134
+ .some(key => record.owner[key] !== owner[key])) {
135
+ throw new Error("Resource registration owner changed; deletion receipt retained for inspection.");
136
+ }
131
137
  next = upsertResourceRecord(next, {
132
138
  ...record,
133
139
  activeRefs: Object.freeze([]),
@@ -0,0 +1,21 @@
1
+ import { resolve } from "node:path";
2
+ import { controllerSocketPath } from "../core/controllerEndpoint.js";
3
+ import { managedIntegrationRuntimeRoot } from "../storage/homeLayout.js";
4
+ import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
5
+ import { FileTaskRuntimeIsolation } from "./taskRuntimeIsolation.js";
6
+ /** One identity/layout for Integration execution and exact-owner cleanup. */
7
+ export function defaultIntegrationRuntimeIsolation(home, homeId) {
8
+ const controlHome = resolve(home);
9
+ const runtimeRoot = managedIntegrationRuntimeRoot(controlHome);
10
+ return new FileTaskRuntimeIsolation({
11
+ runtimeRoot,
12
+ pathLayout: "compact",
13
+ controlPlane: {
14
+ yuiHome: controlHome,
15
+ managedRuntimeRoot: runtimeRoot,
16
+ controllerSocketPath: controllerSocketPath(homeId),
17
+ tmuxNamespace: yuiTmuxServerName(controlHome),
18
+ globalInstallPaths: [process.execPath]
19
+ }
20
+ });
21
+ }
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
5
5
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
6
+ import { scanProcessPathRefs } from "../resources/liveReferences.js";
6
7
  export const YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR = "YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR";
7
8
  export const YUI_TASK_RUNTIME_SERVICE_NAMESPACE = "YUI_TASK_RUNTIME_SERVICE_NAMESPACE";
8
9
  const MARKER_FILE = ".yui-task-runtime-owner.json";
@@ -55,6 +56,10 @@ export class FileTaskRuntimeIsolation {
55
56
  const { descriptor, fingerprint } = validatePreparation(preparation);
56
57
  const root = descriptor.roots.runtime;
57
58
  const existing = inspectRuntimeRoot(descriptor, fingerprint);
59
+ const claim = existing.find(resource => resource.id !== root);
60
+ if (claim !== undefined) {
61
+ throw new Error(`Task runtime has an unresolved cleanup claim: ${claim.id}.`);
62
+ }
58
63
  if (existing.length > 0) {
59
64
  const [resource] = existing;
60
65
  if (resource?.ownership !== "owned"
@@ -97,16 +102,24 @@ export class FileTaskRuntimeIsolation {
97
102
  cleanup(preparation, reason) {
98
103
  requireCleanupReason(reason);
99
104
  const { descriptor, fingerprint } = validatePreparation(preparation);
100
- const resources = [
101
- ...inspectRuntimeRoot(descriptor, fingerprint),
102
- ...(this.#inspectResources?.(descriptor) ?? [])
103
- ];
104
- if (resources.length === 0)
105
+ const resources = inspectTaskRuntimeCleanup(preparation, reason, this.#inspectResources?.(descriptor) ?? []);
106
+ if (resources.length === 0) {
107
+ this.#resourceRegistrar().markTaskRuntimeDeleted(descriptor);
105
108
  return;
106
- planTaskRuntimeCleanup(descriptor, reason, resources);
109
+ }
110
+ // Claims keep the original descriptor and owner marker. An interrupted
111
+ // rename is not absence: resume only exact, inactive claims and leave any
112
+ // partial/unmarked deletion discoverable through the original owner.
113
+ for (const claimed of runtimeCleanupClaims(descriptor.roots.runtime)) {
114
+ removeRuntimeClaim(descriptor, fingerprint, claimed);
115
+ }
107
116
  // Re-read the sole durable marker immediately before deletion. A missing,
108
117
  // replaced, symlinked, or mismatched runtime is never cleanup authority.
109
118
  const current = inspectRuntimeRoot(descriptor, fingerprint);
119
+ if (current.length === 0) {
120
+ this.#resourceRegistrar().markTaskRuntimeDeleted(descriptor);
121
+ return;
122
+ }
110
123
  if (current.length !== 1
111
124
  || current[0]?.ownership !== "owned"
112
125
  || current[0].descriptorFingerprint !== fingerprint) {
@@ -116,18 +129,7 @@ export class FileTaskRuntimeIsolation {
116
129
  const claimed = `${root}.cleanup-${randomBytes(16).toString("hex")}`;
117
130
  renameSync(root, claimed);
118
131
  try {
119
- const marker = parseMarker(readFileSync(join(claimed, MARKER_FILE), "utf8"));
120
- if (marker.fingerprint !== fingerprint
121
- || JSON.stringify(marker.descriptor) !== JSON.stringify(descriptor)) {
122
- throw new Error("Task runtime resources changed during cleanup claim.");
123
- }
124
- rmSync(claimed, { recursive: true });
125
- this.#resourceRegistrar().markPathsDeleted([
126
- descriptor.roots.runtime,
127
- descriptor.roots.data,
128
- descriptor.roots.cache,
129
- descriptor.roots.temporary
130
- ]);
132
+ removeRuntimeClaim(descriptor, fingerprint, claimed);
131
133
  }
132
134
  catch (error) {
133
135
  // Preserve a claimed-but-unverified resource. Restore its exact path only
@@ -140,6 +142,13 @@ export class FileTaskRuntimeIsolation {
140
142
  }
141
143
  throw error;
142
144
  }
145
+ if (inspectRuntimeRoot(descriptor, fingerprint).length !== 0) {
146
+ throw new Error("Task runtime resources changed during cleanup.");
147
+ }
148
+ // Receipt persistence is separate from the filesystem claim. If it fails,
149
+ // retain the caller's owner and retry using the same validated descriptor;
150
+ // do not try to restore an already removed directory.
151
+ this.#resourceRegistrar().markTaskRuntimeDeleted(descriptor);
143
152
  }
144
153
  }
145
154
  export function createTaskRuntimeIsolationDescriptor(input) {
@@ -329,8 +338,69 @@ export function planTaskRuntimeCleanup(descriptor, reason, resources) {
329
338
  }
330
339
  return Object.freeze([...ids].sort());
331
340
  }
341
+ /** Read-only cleanup admission, shared by resource preflight and deletion.
342
+ * Deletion still repeats ownership/live checks after claiming each directory.
343
+ */
344
+ export function inspectTaskRuntimeCleanup(preparation, reason, additionalResources = []) {
345
+ const { descriptor, fingerprint } = validatePreparation(preparation);
346
+ const resources = [...inspectRuntimeRoot(descriptor, fingerprint), ...additionalResources];
347
+ planTaskRuntimeCleanup(descriptor, reason, resources);
348
+ const paths = resources.filter(resource => resource.kind === "directory").map(resource => resource.id);
349
+ if (paths.length === 0)
350
+ return resources;
351
+ const scan = scanProcessPathRefs(paths);
352
+ if (scan.diagnostics.some(diagnostic => diagnostic.severity === "error")
353
+ || [...scan.refs.values()].some(refs => refs.length > 0)) {
354
+ throw new Error("Task runtime cleanup resources are live or unverified.");
355
+ }
356
+ return resources;
357
+ }
332
358
  function inspectRuntimeRoot(descriptor, expectedFingerprint) {
333
- const root = descriptor.roots.runtime;
359
+ return [
360
+ ...inspectRuntimeDirectory(descriptor, expectedFingerprint, descriptor.roots.runtime),
361
+ ...inspectTaskRuntimeCleanupClaims(descriptor)
362
+ ];
363
+ }
364
+ /** Read-only inventory shared by execution admission and physical cleanup.
365
+ * The descriptor remains the original identity after a cleanup rename.
366
+ */
367
+ export function inspectTaskRuntimeCleanupClaims(descriptor) {
368
+ const fingerprint = taskRuntimeIsolationFingerprint(descriptor);
369
+ return runtimeCleanupClaims(descriptor.roots.runtime)
370
+ .flatMap(root => inspectRuntimeDirectory(descriptor, fingerprint, root));
371
+ }
372
+ function runtimeCleanupClaims(root) {
373
+ const parent = dirname(root);
374
+ try {
375
+ // Names select candidates, never deletion authority. Include malformed or
376
+ // partially removed claims too so absence cannot produce a false receipt.
377
+ return readdirSync(parent).filter(name => name.startsWith(`${basename(root)}.cleanup-`))
378
+ .sort().map(name => join(parent, name));
379
+ }
380
+ catch (error) {
381
+ if (isNodeCode(error, "ENOENT"))
382
+ return [];
383
+ throw error;
384
+ }
385
+ }
386
+ function removeRuntimeClaim(descriptor, fingerprint, claimed) {
387
+ const current = inspectRuntimeDirectory(descriptor, fingerprint, claimed);
388
+ if (current.length !== 1 || current[0]?.ownership !== "owned") {
389
+ throw new Error(`Task runtime cleanup claim is not exactly owned: ${claimed}.`);
390
+ }
391
+ // A process cwd/fd follows rename. Inspect the physical claim, not only the
392
+ // descriptor's original (now missing) runtime path.
393
+ const scan = scanProcessPathRefs([claimed]);
394
+ if (scan.diagnostics.some(diagnostic => diagnostic.severity === "error")
395
+ || [...scan.refs.values()].some(refs => refs.length > 0)) {
396
+ throw new Error(`Task runtime cleanup claim is live or unverified: ${claimed}.`);
397
+ }
398
+ if (inspectRuntimeDirectory(descriptor, fingerprint, claimed)[0]?.ownership !== "owned") {
399
+ throw new Error(`Task runtime cleanup claim changed during inspection: ${claimed}.`);
400
+ }
401
+ rmSync(claimed, { recursive: true });
402
+ }
403
+ function inspectRuntimeDirectory(descriptor, expectedFingerprint, root) {
334
404
  let metadata;
335
405
  try {
336
406
  metadata = lstatSync(root);
@@ -30,7 +30,8 @@ export function archiveRetainedResources(store, task) {
30
30
  resources.push({ resource: `run:${task.id}/${run.id}`,
31
31
  detail: `AgentRun remains active (${run.roleName}); archive is not stop evidence.` });
32
32
  }
33
- for (const job of store.listDurableJobs(task.id).filter(j => ["queued", "running", "unknown-needs-attention"].includes(j.status))) {
33
+ for (const job of store.listDurableJobs(task.id).filter(j => ["queued", "running"].includes(j.status)
34
+ || (j.status === "unknown-needs-attention" && j.acknowledgedAt === undefined))) {
34
35
  resources.push({ resource: `job:${task.id}/${job.id}`, detail: `DurableJob remains ${job.status}.` });
35
36
  }
36
37
  for (const attempt of store.listIntegrationAttempts(task.id).filter(a => ["running", "blocked", "conflicted", "validating"].includes(a.status))) {
@@ -110,20 +111,37 @@ export function taskArchiveDiagnostics(store, task) {
110
111
  ...(event.payload.paths === undefined ? {} : { paths: JSON.parse(event.payload.paths) }) });
111
112
  }
112
113
  const forced = archive?.payload.force === "true";
113
- const cleanupFinished = !forced || cleanup.some(e => e.payload.status === "finished");
114
- if (forced && !cleanupFinished)
114
+ const last = cleanup.at(-1);
115
+ const cleanupFinished = last?.payload.status === "finished"
116
+ || (cleanup.length === 0 && archive !== undefined && archive.payload.cleanup !== "pending" && !forced);
117
+ if (task.status === "archived" && !cleanupFinished)
115
118
  warnings.push({ resource: `task:${task.id}`,
116
119
  detail: "Archive committed; cleanup has not finished. Retained references require explicit inspection; archive retry does not replay cleanup." });
120
+ const retainedResources = archiveRetainedResources(store, task);
121
+ const passStart = cleanup.map(e => e.payload.resource === `cleanup-pass:${task.id}` && e.payload.status === "started").lastIndexOf(true);
122
+ const lastPass = cleanup.slice(passStart >= 0 ? passStart
123
+ : cleanup.map(e => e.payload.status).lastIndexOf("finished", cleanup.length - 2) + 1);
124
+ // Derived from current references AND a completed physical cleanup attempt;
125
+ // neither archive nor an empty workspace list alone establishes release.
126
+ const allResourcesReleased = task.status === "archived" && cleanupFinished
127
+ && retainedResources.length === 0
128
+ && lastPass.some(e => e.payload.resource === `runtime:${task.id}` && e.payload.status === "released")
129
+ && !lastPass.some(e => e.payload.status === "retained");
117
130
  return { archived: task.status === "archived", forced,
118
131
  disposition: archive?.payload.workspaceDisposition ?? null,
119
- cleanupFinished, warnings, retainedResources: archiveRetainedResources(store, task),
120
- cleanupEvents: cleanup };
132
+ cleanupFinished, allResourcesReleased,
133
+ warnings: warnings.slice(-32), retainedResources: retainedResources.slice(0, 32),
134
+ counts: { warnings: warnings.length, retainedResources: retainedResources.length, cleanupEvents: cleanup.length },
135
+ cleanupEvents: cleanup.slice(-32),
136
+ actions: [`yui task archive-preflight ${task.id} --${archive?.payload.workspaceDisposition === "abandoned" ? "abandon" : "integrated"}`,
137
+ `yui task archive-cleanup ${task.id}`, `yui task event list ${task.id}`] };
121
138
  }
122
139
  export function renderArchiveDiagnostics(data) {
123
140
  return [
124
- `Archived: ${data.archived}; forced: ${data.forced}; cleanup finished: ${data.cleanupFinished}`,
125
- `Retained references: ${data.retainedResources.length}; a finished cleanup attempt is not physical resource release evidence.`,
141
+ `Archived: ${data.archived}; forced: ${data.forced}; cleanup finished: ${data.cleanupFinished}; all resources released: ${data.allResourcesReleased}`,
142
+ `Retained references: ${data.counts.retainedResources}; a finished cleanup attempt is not physical resource release evidence.`,
126
143
  ...data.warnings.map(w => `Warning [${w.resource}]: ${w.detail}`),
127
- ...data.retainedResources.map(r => `Retained [${r.resource}]: ${r.detail}${r.paths ? ` (${r.paths.join(", ")})` : ""}`)
144
+ ...data.retainedResources.map(r => `Retained [${r.resource}]: ${r.detail}${r.paths ? ` (${r.paths.join(", ")})` : ""}`),
145
+ `Inspect/retry: ${data.actions.join("; ")}`
128
146
  ].join("\n") + "\n";
129
147
  }
@@ -3,6 +3,7 @@ import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePrepare
3
3
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
4
4
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
5
5
  import { cleanupCheckFromError, renderCleanupCheck } from "../workspace/cleanupInspection.js";
6
+ import { jobCleanupBlocker } from "../job/jobCleanupInspection.js";
6
7
  /** Semantic admission facts. Cleanup still has its own resource boundary. */
7
8
  export function archiveSettlementChecks(store, task) {
8
9
  const checks = [];
@@ -13,6 +14,9 @@ export function archiveSettlementChecks(store, task) {
13
14
  for (const item of store.listWorkItems(task.id).filter(i => i.status === "open")) {
14
15
  add(`work-item:${task.id}/${item.id}`, "owner-unsettled", "WorkItem must be accepted or explicitly retired before archive.", ["accepted", "retired"], item.status, `yui task work show ${task.id}/${item.id}`);
15
16
  }
17
+ for (const round of store.listReviewRounds(task.id).filter(r => ["pending", "running"].includes(r.status))) {
18
+ add(`review-round:${task.id}/${round.id}`, "owner-unsettled", "ReviewRound must be terminal before ordinary archive.", ["completed", "failed"], round.status, `yui task context list ${task.id} --store review-round`);
19
+ }
16
20
  for (const attempt of store.listIntegrationAttempts(task.id).filter(a => ["running", "blocked", "conflicted", "validating"].includes(a.status))) {
17
21
  add(`integration-attempt:${task.id}/${attempt.id}`, "unresolved-integration", "Task has an unresolved Integration Attempt.", "terminal attempt", attempt.status, `yui task integration show ${task.id}/${attempt.id}`);
18
22
  }
@@ -24,15 +28,17 @@ export function archiveSettlementChecks(store, task) {
24
28
  /** Unknown execution is never permission to remove workspaces, even after
25
29
  * force admission. These facts are reloaded in the actual force cleanup path.
26
30
  */
27
- export function archiveExecutionChecks(store, taskId) {
31
+ export function archiveExecutionChecks(store, taskId, home, phase = "before-removal") {
28
32
  const checks = [];
29
33
  const add = (resource, reason, detail, observed, action) => checks.push({ resource, reason, status: "unknown", detail, expected: "settled execution",
30
34
  observed, sources: [resource], actions: [action] });
31
35
  for (const run of store.listRuns(taskId).filter(r => r.status === "active")) {
32
36
  add(`run:${taskId}/${run.id}`, "active-turn", "AgentRun remains active; archive is not stop evidence.", run.status, `yui task run show ${taskId}/${run.id}`);
33
37
  }
34
- for (const job of store.listDurableJobs(taskId).filter(j => ["queued", "running", "unknown-needs-attention"].includes(j.status))) {
35
- add(`job:${taskId}/${job.id}`, "unresolved-execution", "DurableJob may still hold resources; acknowledgement is not physical exit evidence.", job.status, `yui job get --task ${taskId} --job ${job.id}`);
38
+ for (const job of store.listDurableJobs(taskId)) {
39
+ const blocker = jobCleanupBlocker(job, home, phase);
40
+ if (blocker !== undefined)
41
+ add(`job:${taskId}/${job.id}`, "unresolved-execution", blocker, job.status, `yui job get --task ${taskId} --job ${job.id}`);
36
42
  }
37
43
  for (const role of store.listRoles(taskId)) {
38
44
  const resource = `role:${taskId}/${role.name}`;
@@ -65,7 +71,7 @@ export async function inspectTaskArchive(coordinator, request) {
65
71
  observed: { coverage: p.coverage, merged: p.merged, verified: p.verified, localCommit: p.publication?.localCommit ?? null },
66
72
  sources: [`task:${task.id}`, ...(p.publication === null ? [] : [`publication:${task.id}/${p.publication.id}`])],
67
73
  actions: [`yui task remote-delivery ${task.id}`] }));
68
- const execution = archiveExecutionChecks(store, task.id);
74
+ const execution = archiveExecutionChecks(store, task.id, preparer.home);
69
75
  const runtimeResource = `runtime:${task.id}`;
70
76
  if (runtime.assertTaskPhysicalResourcesReleased === undefined) {
71
77
  execution.push(...cleanupCheckFromError(null, runtimeResource, [`task:${task.id}`], [`yui task role status ${task.id}`]));
@@ -100,25 +106,36 @@ export async function inspectTaskArchive(coordinator, request) {
100
106
  checks });
101
107
  }
102
108
  const eligible = ["completed", "cancelled", "archived"].includes(task.status);
109
+ const limit = 32;
110
+ const boundedExecution = execution.slice(0, limit);
103
111
  return { taskId: task.id, observedAt: new Date().toISOString(), disposition: request.disposition, force: request.force,
104
112
  readOnly: true, authorizesCleanup: false,
105
113
  archive: { status: task.status, eligible, alreadyArchived: task.status === "archived",
106
- settlement, delivery: deliveryChecks, forceBypassesSettlement: request.force,
114
+ settlement: settlement.slice(0, limit), delivery: deliveryChecks.slice(0, limit), forceBypassesSettlement: request.force,
115
+ execution: boundedExecution, physicalRetentionBlocksArchive: false,
107
116
  requiresIndependentAuthorization: true },
108
- cleanup: { execution, resources },
109
- note: "Current observations only. Execution reloads the checks. Force commits archive first and retains unsafe resources; a finished attempt does not prove resource release." };
117
+ cleanup: { execution: boundedExecution, resources: resources.slice(0, limit).map(resource => ({
118
+ ...resource, checkCount: resource.checks.length, checks: resource.checks.slice(0, limit)
119
+ })) },
120
+ counts: { settlement: settlement.length, delivery: deliveryChecks.length,
121
+ execution: execution.length, resources: resources.length },
122
+ actions: [`yui task context list ${task.id} --store managed-workspace`,
123
+ `yui task remote-delivery ${task.id}`, `yui task event list ${task.id}`],
124
+ note: "Current observations only. Ordinary archive requires settled business, exact delivery and stopped execution; safe physical retention is separate. Cleanup reloads these checks after archive commits. A finished attempt does not prove resource release." };
110
125
  }
111
126
  export function renderTaskArchivePreflight(data) {
112
127
  return [
113
128
  `Archive preflight: ${data.taskId} (${data.disposition}${data.force ? ", force" : ""}); read-only, not authorization`,
114
129
  `Task: ${data.archive.status}; terminal eligibility: ${data.archive.eligible}`,
130
+ `Checks/resources (showing at most 32 per list): settlement=${data.counts.settlement}; delivery=${data.counts.delivery}; execution=${data.counts.execution}; resources=${data.counts.resources}`,
115
131
  ...data.archive.settlement.map(c => `Admission${data.force ? " (force preserves)" : ""}: ${renderCleanupCheck(c)}`),
116
132
  ...data.archive.delivery.map(c => `Delivery${data.force || data.disposition === "abandoned" ? " (advisory for admission)" : ""}: ${renderCleanupCheck(c)}`),
117
133
  ...data.cleanup.execution.map(c => `Cleanup: ${renderCleanupCheck(c)}`),
118
134
  ...data.cleanup.resources.flatMap(r => [
119
- `Workspace [${r.resource}]: ${r.status}`,
135
+ `Workspace [${r.resource}]: ${r.status}; checks=${r.checkCount}`,
120
136
  ...r.checks.map(c => ` ${renderCleanupCheck(c)}`)
121
137
  ]),
122
- data.note
138
+ data.note,
139
+ `Inspect full evidence: ${data.actions.join("; ")}`
123
140
  ].join("\n") + "\n";
124
141
  }
@@ -5,6 +5,7 @@ import { archiveExecutionChecks, archiveSettlementChecks } from "./archivePrefli
5
5
  import { admitLeaderArchive, assertLeaderArchiveAdmission } from "./leaderArchiveAuthority.js";
6
6
  import { assertTaskRemoteDeliveryIntegrated, createTaskRemoteDeliveryProof } from "./remoteDeliveryService.js";
7
7
  import { selectNewTaskRoleSession } from "../executor/agentExecutor.js";
8
+ import { taskArchiveDiagnostics } from "./archiveDiagnostics.js";
8
9
  /** A single foreground Controller operation survives stopping its requesting
9
10
  * Session. It has no retry worker: uncertain receipts are inspected, not
10
11
  * replayed. All existing settlement, delivery and cleanup checks remain. */
@@ -28,7 +29,7 @@ export async function archiveLeaderTask(store, coordinator, taskId, environment,
28
29
  // Only the requesting no-Run Leader may be stopped as part of this action.
29
30
  // Another Run, Job, unknown input, or then handoff is not archive authority.
30
31
  const ownResources = `role:${taskId}/leader`;
31
- const execution = archiveExecutionChecks(store, taskId).filter(check => !(check.resource === ownResources
32
+ const execution = archiveExecutionChecks(store, taskId, coordinator.preparer.home, "before-role-stop").filter(check => !(check.resource === ownResources
32
33
  && ownInput?.runId === undefined && ownInput?.status === "accepted"
33
34
  && ["unresolved-provider-input", "unresolved-mailbox"].includes(check.reason)));
34
35
  if (execution.length)
@@ -39,7 +40,7 @@ export async function archiveLeaderTask(store, coordinator, taskId, environment,
39
40
  }
40
41
  let candidate = null;
41
42
  const workspace = store.getTaskWorkspace(taskId);
42
- if (task.projectBindings.length && workspace) {
43
+ if (task.status === "cancelled" && task.projectBindings.length && workspace) {
43
44
  const snapshot = await coordinator.preparer.snapshotDirectTaskMain(workspace, task.projectBindings.map(binding => binding.projectId));
44
45
  candidate = { schemaVersion: 1, projects: snapshot.projects.map(project => ({
45
46
  projectId: project.projectId, commit: project.headCommit
@@ -47,14 +48,6 @@ export async function archiveLeaderTask(store, coordinator, taskId, environment,
47
48
  }
48
49
  const proof = createTaskRemoteDeliveryProof(store, task, candidate);
49
50
  assertTaskRemoteDeliveryIntegrated(proof.delivery);
50
- for (const owned of store.listManagedWorkspaces(taskId)) {
51
- const disposition = owned.owner.type === "work-item"
52
- && store.getWorkItem(taskId, owned.owner.workItemId)?.status === "retired" ? "abandoned" : "integrated";
53
- const checks = await coordinator.preparer.inspectWorkspaceCleanup(owned, disposition);
54
- if (checks.some(check => !(owned.owner.type === "task" && check.reason === "git-worktree-registrations"))) {
55
- throw new Error(`Archive workspace is not clean/removable: ${JSON.stringify(checks)}`);
56
- }
57
- }
58
51
  // Reauthenticate after asynchronous inspection, before the first effect.
59
52
  admitLeaderArchive(store, taskId, environment, request.sourceMessage, request.purpose);
60
53
  assertLeaderArchiveAdmission(store, taskId, admission);
@@ -94,14 +87,15 @@ export async function archiveLeaderTask(store, coordinator, taskId, environment,
94
87
  // the first verified stop already released its physical owner records.
95
88
  tx.saveTaskRoleSessionSet(selectNewTaskRoleSession(stopped, stopped.activeAgentId, new Date()));
96
89
  });
97
- const cleanup = await coordinator.cleanupTaskForArchive(taskId, "integrated");
98
- if (cleanup.status !== "removed")
99
- throw new Error(cleanup.error ?? `Archive cleanup ${cleanup.status}.`);
90
+ await coordinator.prepareTaskForArchive(taskId);
100
91
  runTaskCommand(["archive", taskId, "--integrated"], store, {
101
- environment, archiveLeaderAdmission: admission, archiveRemoteDeliveryProof: proof
92
+ environment, yuiHome: coordinator.preparer.home,
93
+ archiveLeaderAdmission: admission, archiveRemoteDeliveryProof: proof
102
94
  });
95
+ await coordinator.cleanupArchivedTask(taskId, "integrated");
103
96
  record("task.leader-archive-result", { status: "archived" });
104
- return { taskId, requestId: request.requestId, status: "archived" };
97
+ return { taskId, requestId: request.requestId, status: "archived",
98
+ ...taskArchiveDiagnostics(store, store.getTask(taskId)) };
105
99
  }
106
100
  catch (error) {
107
101
  record("task.leader-archive-result", { status: store.getTask(taskId)?.status === "archived" ? "archived" : "failed",