@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.
- package/README.md +8 -2
- package/dist/cli/commandCatalog.js +32 -6
- package/dist/cli/commandDiscovery.js +4 -1
- package/dist/cli/interactionPolicy.js +4 -0
- package/dist/cli.js +23 -3078
- package/dist/commands/taskCommands.js +84 -28
- package/dist/commands/taskIntegrationCommands.js +8 -17
- package/dist/commands/taskUpstreamCommands.js +3 -1
- package/dist/context/runContextPack.js +132 -5
- package/dist/context/sourceRunContext.js +4 -2
- package/dist/context/taskContext.js +6 -1
- package/dist/controlPlaneCli.js +3099 -0
- package/dist/controller/clientRuntime.js +3 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
- package/dist/controller/jobControl.js +21 -0
- package/dist/executor/agentExecutor.js +1 -1
- package/dist/executor/effectiveLaunch.js +18 -5
- package/dist/executor/fileRoleLaunchPlanner.js +15 -19
- package/dist/integration/gitIntegrationService.js +58 -82
- package/dist/job/jobCleanupInspection.js +67 -0
- package/dist/message/messageContinuation.js +7 -2
- package/dist/nativeAgent/agent.js +176 -74
- package/dist/nativeAgent/cliDemo.js +37 -0
- package/dist/nativeAgent/codingTools.js +11 -0
- package/dist/nativeAgent/commandTool.js +215 -0
- package/dist/nativeAgent/compactionDemo.js +158 -0
- package/dist/nativeAgent/composition.js +71 -0
- package/dist/nativeAgent/context/budget.js +44 -0
- package/dist/nativeAgent/context/index.js +339 -0
- package/dist/nativeAgent/context/providerCompressor.js +103 -0
- package/dist/nativeAgent/demo.js +12 -1
- package/dist/nativeAgent/evaluation/cases.js +38 -0
- package/dist/nativeAgent/evaluation/checks.js +91 -0
- package/dist/nativeAgent/evaluation/demo.js +19 -0
- package/dist/nativeAgent/evaluation/files.js +54 -0
- package/dist/nativeAgent/evaluation/fixture.js +36 -0
- package/dist/nativeAgent/evaluation/index.js +239 -0
- package/dist/nativeAgent/executionOwner.js +209 -0
- package/dist/nativeAgent/filePatterns.js +170 -0
- package/dist/nativeAgent/fileToolsSupport.js +202 -0
- package/dist/nativeAgent/index.js +13 -0
- package/dist/nativeAgent/interaction/cli.js +358 -0
- package/dist/nativeAgent/interaction/contracts.js +1 -0
- package/dist/nativeAgent/interaction/index.js +3 -0
- package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
- package/dist/nativeAgent/interaction/renderer.js +34 -0
- package/dist/nativeAgent/localSafety.js +258 -0
- package/dist/nativeAgent/model/anthropicMessages.js +204 -0
- package/dist/nativeAgent/model/chatCompletions.js +210 -0
- package/dist/nativeAgent/model/errors.js +47 -0
- package/dist/nativeAgent/model/gateway.js +423 -0
- package/dist/nativeAgent/model/index.js +7 -0
- package/dist/nativeAgent/model/observationAdapter.js +21 -0
- package/dist/nativeAgent/model/protocols.js +19 -0
- package/dist/nativeAgent/model/responses.js +263 -0
- package/dist/nativeAgent/model/types.js +1 -0
- package/dist/nativeAgent/model/wire.js +73 -0
- package/dist/nativeAgent/observability/index.js +220 -0
- package/dist/nativeAgent/product/catalog.js +82 -0
- package/dist/nativeAgent/product/config.js +295 -0
- package/dist/nativeAgent/product/facts.js +30 -0
- package/dist/nativeAgent/product/index.js +62 -0
- package/dist/nativeAgent/product/location.js +44 -0
- package/dist/nativeAgent/product/runtime.js +276 -0
- package/dist/nativeAgent/product/storage.js +49 -0
- package/dist/nativeAgent/product/tools.js +47 -0
- package/dist/nativeAgent/product/transport.js +54 -0
- package/dist/nativeAgent/projectGuidance/index.js +425 -0
- package/dist/nativeAgent/searchTools.js +305 -0
- package/dist/nativeAgent/session/backends.js +293 -0
- package/dist/nativeAgent/session/catalog.js +97 -0
- package/dist/nativeAgent/session/catalogDemo.js +87 -0
- package/dist/nativeAgent/session/contracts.js +1 -0
- package/dist/nativeAgent/session/format.js +269 -0
- package/dist/nativeAgent/session/index.js +5 -0
- package/dist/nativeAgent/session/location.js +36 -0
- package/dist/nativeAgent/session/sqliteFormat.js +134 -0
- package/dist/nativeAgent/session/store.js +248 -0
- package/dist/nativeAgent/textTools.js +270 -133
- package/dist/nativeAgent/toolManager/executor.js +290 -0
- package/dist/nativeAgent/toolManager/index.js +4 -0
- package/dist/nativeAgent/validation.js +2 -2
- package/dist/repository/gitWorkspace.js +41 -12
- package/dist/repository/taskWorkspaceCoordinator.js +72 -145
- package/dist/repository/taskWorkspacePreparer.js +39 -43
- package/dist/repository/workspaceCleanupInspection.js +88 -7
- package/dist/resources/resourceRegistrar.js +16 -10
- package/dist/runtime/integrationRuntimeIsolation.js +21 -0
- package/dist/runtime/taskRuntimeIsolation.js +91 -21
- package/dist/task/archiveDiagnostics.js +26 -8
- package/dist/task/archivePreflight.js +26 -9
- package/dist/task/leaderArchive.js +9 -15
- package/dist/task/taskAuthority.js +56 -0
- package/dist/web/assets/client/api.js +8 -0
- package/dist/web/assets/client/app.js +74 -9
- package/dist/web/assets/client/components.js +95 -36
- package/dist/web/assets/client/detail.js +110 -64
- package/dist/web/assets/client/dock.js +95 -11
- package/dist/web/assets/client/evidence.js +286 -94
- package/dist/web/assets/client/i18n.js +182 -62
- package/dist/web/assets/client/overview.js +9 -10
- package/dist/web/assets/client/records.js +142 -7
- package/dist/web/assets/client/sections.js +234 -198
- package/dist/web/assets/client/sidebar.js +31 -2
- package/dist/web/assets/icons.js +4 -0
- package/dist/web/assets/shell.js +2 -1
- package/dist/web/assets/styles/components.js +64 -15
- package/dist/web/assets/styles/layout.js +11 -4
- package/dist/web/assets/styles/responsive.js +16 -6
- package/dist/web/assets/styles/tokens.js +1 -1
- package/dist/web/assets/styles/views.js +62 -42
- package/dist/web/webServer.js +12 -1
- package/dist/web/webTaskSurface.js +8 -1
- package/docs/agent-result-consumption.md +16 -0
- package/docs/agent-result-consumption.zh-CN.md +13 -0
- package/docs/examples/agent-offline.mjs +194 -0
- package/docs/native-agent.md +283 -0
- package/docs/observability/README.md +13 -3
- package/docs/observability/README.zh-CN.md +10 -3
- package/docs/release-workflow.md +72 -9
- package/docs/release-workflow.zh-CN.md +55 -6
- package/docs/roles-and-configuration.md +32 -0
- package/docs/roles-and-configuration.zh-CN.md +26 -0
- package/docs/task-delivery.md +36 -8
- package/docs/task-delivery.zh-CN.md +24 -7
- package/i18n/README.zh-CN.md +3 -2
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +9 -0
- package/skills/yui-leader/references/execution.md +2 -1
- package/skills/yui-leader/references/integration.md +20 -0
- package/skills/yui-operator/SKILL.md +4 -0
- package/skills/yui-reviewer/SKILL.md +8 -0
- package/skills/yui-runtime/SKILL.md +11 -0
- package/skills/yui-runtime/references/publication.md +20 -4
- 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"
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
114
|
-
|
|
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,
|
|
120
|
-
|
|
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
|
|
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)
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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",
|