@zq-silk/yui 2.2.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 (51) hide show
  1. package/README.md +4 -2
  2. package/dist/cli/commandCatalog.js +8 -3
  3. package/dist/cli/interactionPolicy.js +4 -0
  4. package/dist/commands/taskCommands.js +5 -7
  5. package/dist/commands/taskIntegrationCommands.js +5 -16
  6. package/dist/controlPlaneCli.js +28 -21
  7. package/dist/controller/clientRuntime.js +3 -2
  8. package/dist/integration/gitIntegrationService.js +8 -67
  9. package/dist/job/jobCleanupInspection.js +67 -0
  10. package/dist/repository/gitWorkspace.js +41 -12
  11. package/dist/repository/taskWorkspaceCoordinator.js +72 -145
  12. package/dist/repository/taskWorkspacePreparer.js +39 -43
  13. package/dist/repository/workspaceCleanupInspection.js +88 -7
  14. package/dist/resources/resourceRegistrar.js +16 -10
  15. package/dist/runtime/integrationRuntimeIsolation.js +21 -0
  16. package/dist/runtime/taskRuntimeIsolation.js +91 -21
  17. package/dist/task/archiveDiagnostics.js +26 -8
  18. package/dist/task/archivePreflight.js +26 -9
  19. package/dist/task/leaderArchive.js +9 -15
  20. package/dist/web/assets/client/api.js +8 -0
  21. package/dist/web/assets/client/app.js +10 -6
  22. package/dist/web/assets/client/components.js +95 -36
  23. package/dist/web/assets/client/detail.js +110 -64
  24. package/dist/web/assets/client/dock.js +95 -11
  25. package/dist/web/assets/client/evidence.js +286 -94
  26. package/dist/web/assets/client/i18n.js +178 -62
  27. package/dist/web/assets/client/overview.js +9 -13
  28. package/dist/web/assets/client/records.js +142 -7
  29. package/dist/web/assets/client/sections.js +234 -198
  30. package/dist/web/assets/icons.js +4 -0
  31. package/dist/web/assets/styles/components.js +64 -17
  32. package/dist/web/assets/styles/responsive.js +7 -5
  33. package/dist/web/assets/styles/tokens.js +1 -1
  34. package/dist/web/assets/styles/views.js +48 -31
  35. package/dist/web/webServer.js +12 -1
  36. package/dist/web/webTaskSurface.js +8 -1
  37. package/docs/observability/README.md +13 -3
  38. package/docs/observability/README.zh-CN.md +10 -3
  39. package/docs/release-workflow.md +25 -0
  40. package/docs/release-workflow.zh-CN.md +19 -0
  41. package/docs/task-delivery.md +36 -8
  42. package/docs/task-delivery.zh-CN.md +24 -7
  43. package/i18n/README.zh-CN.md +3 -2
  44. package/package.json +1 -1
  45. package/skills/yui-leader/references/execution.md +2 -1
  46. package/skills/yui-leader/references/integration.md +20 -0
  47. package/skills/yui-operator/SKILL.md +4 -0
  48. package/skills/yui-reviewer/SKILL.md +3 -0
  49. package/skills/yui-runtime/SKILL.md +4 -0
  50. package/skills/yui-runtime/references/publication.md +20 -4
  51. package/skills/yui-worker/SKILL.md +3 -0
package/README.md CHANGED
@@ -162,8 +162,10 @@ The task list remains at the left, task details occupy the primary reading area,
162
162
  and recorded Task messages live in a secondary discussion pane. You can resize,
163
163
  swap or hide that pane; on narrow screens it opens over the detail. Native Sessions
164
164
  remain separate from recorded Task messages.
165
- The detail prioritizes user input, native Session activity, Task progress and key
166
- conclusions; fixed file results and delivery evidence open on demand. See
165
+ The detail has four tabs: Overview (user input, progress and key Decisions),
166
+ Delivery (WorkItems, Reviews, Integration and remote delivery, read on demand),
167
+ Runtime (Roles, native Session activity, open execution and usage) and Records
168
+ (a timeline of Decisions, milestones, executions and questions). See
167
169
  [attention and progress](docs/observability/README.md#web-attention-and-progress).
168
170
  See [Web permissions](docs/architecture/capabilities-and-resources.md#cli-and-web)
169
171
  and [input timing](docs/managed-turn-and-session-runtime.md#input-timing-queue-steer-and-interrupt).
@@ -422,9 +422,14 @@ const taskChildren = [
422
422
  usage: "yui task archive-preflight <id> (--integrated|--abandon) [--force] [--json]",
423
423
  options: ["--integrated", "--abandon", "--force", "--json"]
424
424
  },
425
+ {
426
+ name: "archive-cleanup", discovery: { surface: "public", audiences: ["public", "operator", "leader"] },
427
+ summary: "Retry one foreground exact-owner cleanup for an archived Task; retain unsafe resources and all history.",
428
+ usage: "yui task archive-cleanup <id>"
429
+ },
425
430
  {
426
431
  name: "archive", discovery: { surface: "public", audiences: ["public", "operator", "leader"], optionAudiences: { "--force": ["public", "operator"] } },
427
- summary: "Archive a terminal Task; explicit --force commits despite delivery/cleanup warnings, retaining unsafe resources.",
432
+ summary: "Archive settled, delivered, stopped work, then attempt exact-owner cleanup. Retained physical resources do not require --force; force separately bypasses business admission.",
428
433
  usage: "yui task archive <id> (--integrated|--abandon) [--force] [--source-message <id> --purpose <verbatim-authorization> --request-id <id>]",
429
434
  options: ["--integrated", "--abandon", "--force", "--source-message", "--purpose", "--request-id"]
430
435
  },
@@ -992,7 +997,7 @@ const taskChildren = [
992
997
  },
993
998
  { name: "list", discovery: { surface: "managed", audiences: ["operator", "leader"] }, summary: "List Integration Attempts.", usage: "yui task integration list <task>" },
994
999
  { name: "show", discovery: { surface: "managed", audiences: ["operator", "leader"] }, summary: "Show one Integration Attempt.", usage: "yui task integration show <task>/<integration>" },
995
- { name: "cleanup", discovery: { surface: "managed", audiences: ["operator", "leader"] }, summary: "Remove a terminal Integration worktree and branch.", usage: "yui task integration cleanup <task>/<integration>" }
1000
+ { name: "cleanup", discovery: { surface: "managed", audiences: ["operator", "leader"] }, summary: "Release clean terminal Integration resources; preserve attempt history, check logs and dirty diagnostics.", usage: "yui task integration cleanup <task>/<integration>" }
996
1001
  ]
997
1002
  },
998
1003
  {
@@ -1579,7 +1584,7 @@ export const ROOT_COMMAND = buildNode({
1579
1584
  name: "task", discovery: { surface: "public", audiences: ["public", "operator"] },
1580
1585
  summary: "Manage Tasks, WorkItems, AgentRuns, and integration.",
1581
1586
  sections: [
1582
- { id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "base", "update", "activate", "activation", "execution", "complete", "cancel", "reopen", "retire", "list", "show", "context", "next-action", "remote-delivery", "archive-preflight", "archive", "replace", "reconcile", "upstream", "artifact"] },
1587
+ { id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "base", "update", "activate", "activation", "execution", "complete", "cancel", "reopen", "retire", "list", "show", "context", "next-action", "remote-delivery", "archive-preflight", "archive", "archive-cleanup", "replace", "reconcile", "upstream", "artifact"] },
1583
1588
  { id: "collaboration", title: "Collaboration", entries: ["message", "input", "grant", "workflow", "publication", "work", "run", "review", "integration", "role", "overlap", "change-set"] },
1584
1589
  { id: "knowledge", title: "Task Knowledge", entries: ["brief", "decision", "milestone", "event", "continuation", "wake"] }
1585
1590
  ],
@@ -236,6 +236,10 @@ export const INTERACTION_POLICIES = Object.freeze([
236
236
  ...taskTarget("archive-preflight"),
237
237
  trailingOptions: { "--integrated": "flag", "--abandon": "flag", "--force": "flag", "--json": "flag" }
238
238
  },
239
+ {
240
+ ...taskTarget("archive-cleanup", 2, ["archived"]),
241
+ confirmation: { action: "Clean archived Task resources", targetArgumentIndex: 2 }
242
+ },
239
243
  {
240
244
  ...taskTarget("archive", 2, ["completed", "cancelled"]),
241
245
  trailingOptions: { "--integrated": "flag", "--abandon": "flag", "--force": "flag" },
@@ -55,7 +55,7 @@ import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
55
55
  import { planningRuntimeCwd } from "../storage/homeLayout.js";
56
56
  import { StorageConflictError } from "../storage/taskStore.js";
57
57
  import { archiveDeliveryWarnings, archiveRetainedResources, renderArchiveDiagnostics, taskArchiveDiagnostics } from "../task/archiveDiagnostics.js";
58
- import { archiveSettlementChecks } from "../task/archivePreflight.js";
58
+ import { archiveExecutionChecks, archiveSettlementChecks } from "../task/archivePreflight.js";
59
59
  import { projectCompletionReadiness } from "../task/completionReadiness.js";
60
60
  import { assertDraftTaskExecutionFree, validateDraftWorkItemEdit } from "../task/draftPlan.js";
61
61
  import { assertTaskRemoteDeliveryProof, projectTaskRemoteDeliveryFromStore } from "../task/remoteDeliveryService.js";
@@ -1300,12 +1300,9 @@ function archiveTaskCommand(args, store, options) {
1300
1300
  ? assertTaskRemoteDeliveryProof(tx, task, options.archiveRemoteDeliveryProof)
1301
1301
  : undefined;
1302
1302
  if (!request.force) {
1303
- const checks = archiveSettlementChecks(tx, task);
1303
+ const checks = [...archiveSettlementChecks(tx, task), ...archiveExecutionChecks(tx, task.id, options.yuiHome)];
1304
1304
  if (checks.length > 0)
1305
1305
  throw new CleanupInspectionError(checks);
1306
- if (task.cwd !== undefined || tx.listManagedWorkspaces(task.id).length > 0) {
1307
- throw usageError(`Task ${task.id} still has managed worktrees; clean them before archiving.`);
1308
- }
1309
1306
  const activeRole = tx.listRoles(task.id)
1310
1307
  .find((role) => tx.getActiveRun(task.id, role.name) !== null);
1311
1308
  if (activeRole !== undefined) {
@@ -1322,7 +1319,7 @@ function archiveTaskCommand(args, store, options) {
1322
1319
  + "stop that Session before archiving.");
1323
1320
  }
1324
1321
  }
1325
- const retainedResources = request.force ? archiveRetainedResources(tx, task) : [];
1322
+ const retainedResources = archiveRetainedResources(tx, task);
1326
1323
  const warnings = request.force && remoteDelivery !== undefined ? archiveDeliveryWarnings(remoteDelivery) : [];
1327
1324
  const archived = { ...archiveTask(task, now, { by: actor }), executionGate: { state: "stopped" } };
1328
1325
  tx.saveTask(archived);
@@ -1351,8 +1348,9 @@ function archiveTaskCommand(args, store, options) {
1351
1348
  authorizationSource: JSON.stringify(options.archiveLeaderAdmission.source)
1352
1349
  }),
1353
1350
  workspaceDisposition: request.disposition,
1351
+ cleanup: "pending",
1354
1352
  ...(request.force ? {
1355
- force: "true", cleanup: "pending",
1353
+ force: "true",
1356
1354
  warnings: JSON.stringify(warnings),
1357
1355
  retainedResources: JSON.stringify(retainedResources)
1358
1356
  } : {}),
@@ -49,19 +49,8 @@ async function cleanupIntegration(args, store, home, environment) {
49
49
  && integration.status !== "failed") {
50
50
  throw usageError(`Integration is not terminal: ${integration.id}/${integration.status}.`);
51
51
  }
52
- // rr4/finding-5: An Integration Attempt with an active DurableJob cannot be
53
- // cleaned up — the runner may still be using its worktree. Block on queued,
54
- // running, and unacknowledged unknown-needs-attention jobs owned by it.
55
- const activeIntegrationJob = store.listDurableJobs(integration.taskId).find((job) => (job.owner.kind === "integration-attempt"
56
- && job.owner.integrationAttemptId === integration.id
57
- && (job.status === "queued"
58
- || job.status === "running"
59
- || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))));
60
- if (activeIntegrationJob !== undefined) {
61
- throw usageError(`Integration ${integration.id} has an active DurableJob: `
62
- + `${activeIntegrationJob.id}/${activeIntegrationJob.status}. `
63
- + "Cancel or acknowledge it before cleanup.");
64
- }
52
+ // The service's shared owner inspection checks both Job settlement and
53
+ // current physical occupancy before any runtime or worktree is released.
65
54
  const result = await new GitIntegrationService(home, store).cleanup(integration, {
66
55
  authorize: () => taskLocalActor(store, environment, integration.taskId)
67
56
  });
@@ -70,9 +59,9 @@ async function cleanupIntegration(args, store, home, environment) {
70
59
  }
71
60
  return {
72
61
  output: result === "removed"
73
- ? `Cleaned Integration worktree and check logs ${integration.id}\n`
74
- : `Integration worktree and check logs already clean: ${integration.id}\n`,
75
- data: { integrationId: integration.id, cleanup: result }
62
+ ? `Cleaned Integration worktree ${integration.id}; history and check logs retained\n`
63
+ : `Integration worktree already clean: ${integration.id}; history and check logs retained\n`,
64
+ data: { integrationId: integration.id, cleanup: result, historyRetained: true }
76
65
  };
77
66
  }
78
67
  async function start(args, store, home, now, options) {
@@ -1012,6 +1012,24 @@ export async function main() {
1012
1012
  emit(renderTaskArchivePreflight(data), false, data);
1013
1013
  return;
1014
1014
  }
1015
+ if (resolved[1] === "archive-cleanup") {
1016
+ const taskId = resolved[2];
1017
+ if (resolved.length !== 3 || taskId === undefined) {
1018
+ throw usageError("Usage: yui task archive-cleanup <task>.");
1019
+ }
1020
+ taskLocalActor(store, process.env, taskId);
1021
+ const task = store.getTask(taskId);
1022
+ if (task?.status !== "archived")
1023
+ throw usageError("Archive cleanup requires an archived Task.");
1024
+ const archive = taskArchiveDiagnostics(store, task);
1025
+ if (archive.disposition !== "integrated" && archive.disposition !== "abandoned") {
1026
+ throw usageError("Archive has no recorded cleanup disposition; inspect its original event.");
1027
+ }
1028
+ await workspaceCoordinator.cleanupArchivedTask(taskId, archive.disposition);
1029
+ const result = taskArchiveDiagnostics(store, store.getTask(taskId));
1030
+ emit(renderArchiveDiagnostics(result), false, { taskId, ...result });
1031
+ return;
1032
+ }
1015
1033
  if (resolved[1] === "artifact") {
1016
1034
  // File/directory artifacts live in the Task's local Git repository, so
1017
1035
  // their save/read/list are asynchronous and handled here rather than in
@@ -1343,30 +1361,19 @@ export async function main() {
1343
1361
  }
1344
1362
  {
1345
1363
  if (disposition === "integrated") {
1346
- archiveTaskReviewCandidate = await actualTaskReviewCandidateForTaskCommand(resolved, store, workspacePreparer, process.env);
1364
+ archiveTaskReviewCandidate = task.status === "cancelled" ? await actualTaskReviewCandidateForTaskCommand(resolved, store, workspacePreparer, process.env) : undefined;
1347
1365
  archiveRemoteDeliveryProof = createTaskRemoteDeliveryProof(store, task, archiveTaskReviewCandidate ?? null);
1348
1366
  assertTaskRemoteDeliveryIntegrated(archiveRemoteDeliveryProof.delivery);
1349
1367
  }
1350
- const workItemIds = store.listManagedWorkspaces(task.id)
1351
- .flatMap(({ owner }) => owner.type === "work-item" ? [owner.workItemId] : []);
1352
- for (const workItemId of workItemIds) {
1353
- const item = store.getWorkItem(task.id, workItemId);
1354
- if (item?.status !== "accepted" || disposition !== "integrated")
1355
- continue;
1356
- try {
1357
- await new WorkItemChangeSetManager(store).assertIntegrated(task.id, item.id);
1358
- }
1359
- catch (error) {
1360
- throw cleanupCliError(error, `work-item:${task.id}/${item.id}`);
1361
- }
1362
- }
1363
- const cleanup = await workspaceCoordinator.cleanupTaskForArchive(task.id, disposition);
1364
- if (cleanup.status === "retained-dirty") {
1365
- throw usageError(cleanup.error ?? `Task ${task.id} has dirty managed worktrees and remains terminal.`, undefined, cleanupBlockedDetails(cleanup.reason ?? "dirty-worktree", cleanup.resource ?? `task:${task.id}`, cleanup.retryable ?? true, cleanup.checks));
1366
- }
1367
- if (cleanup.status === "failed") {
1368
- throw usageError(`Task ${task.id} worktree cleanup failed: ${cleanup.error ?? "unknown error"}.`, undefined, cleanupBlockedDetails(cleanup.reason ?? "cleanup-failed", cleanup.resource ?? `task:${task.id}`, cleanup.retryable ?? true, cleanup.checks));
1369
- }
1368
+ await workspaceCoordinator.prepareTaskForArchive(task.id);
1369
+ runTaskCommand(resolved.slice(1), store, {
1370
+ runtime, environment: process.env, yuiHome: home, archiveRemoteDeliveryProof
1371
+ });
1372
+ await workspaceCoordinator.cleanupArchivedTask(task.id, disposition);
1373
+ const current = store.getTask(task.id);
1374
+ const archive = taskArchiveDiagnostics(store, current);
1375
+ emit(`Archived task ${task.id}\n${renderArchiveDiagnostics(archive)}`, false, { task: current, ...archive });
1376
+ return;
1370
1377
  }
1371
1378
  }
1372
1379
  let taskRetirementProof;
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { jobCleanupBlocker } from "../job/jobCleanupInspection.js";
2
3
  import { describeCliFailure } from "../errors/cliFailure.js";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "../core/controllerProcessIdentity.js";
@@ -525,8 +526,8 @@ export class FileTaskWorkflowRuntime {
525
526
  * live, preserving the owner records for Operator recovery.
526
527
  */
527
528
  async assertTaskPhysicalResourcesReleased(taskId) {
528
- const activeJobs = this.store.listActiveDurableJobs()
529
- .filter((job) => job.taskId === taskId);
529
+ const activeJobs = this.store.listDurableJobs(taskId)
530
+ .filter((job) => jobCleanupBlocker(job, this.home) !== undefined);
530
531
  if (activeJobs.length > 0) {
531
532
  throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task physical resources are not released: ${activeJobs.length} DurableJob(s) are still active: `
532
533
  + activeJobs.map(({ id }) => id).join(", "));
@@ -3,19 +3,17 @@ import { lstat, mkdir, open, realpath, rm, stat } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { selectEnvironment } from "../agent/launchEnvironment.js";
6
- import { controllerSocketPath } from "../core/controllerEndpoint.js";
7
6
  import { durableJobIdempotencyKey } from "../job/durableJob.js";
8
7
  import { readRuntimeIdentity } from "../release/runtimeRelease.js";
9
8
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
10
9
  import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
11
10
  import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
12
- import { integrationWorkspaceRoot } from "../repository/taskWorkspacePreparer.js";
11
+ import { FileTaskWorkspacePreparer, integrationWorkspaceRoot } from "../repository/taskWorkspacePreparer.js";
13
12
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
14
- import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
15
- import { integrationTmuxSocketRoot, managedIntegrationRuntimeRoot } from "../storage/homeLayout.js";
13
+ import { integrationTmuxSocketRoot } from "../storage/homeLayout.js";
14
+ import { defaultIntegrationRuntimeIsolation } from "../runtime/integrationRuntimeIsolation.js";
16
15
  import { advanceTaskProjectCommit } from "../task/task.js";
17
16
  import { taskIntegrationSettlementCheckout, taskIntegrationTargetCheckout } from "../task/taskAuthority.js";
18
- import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
19
17
  import { recordGateArtifactReuse } from "../verification/gateArtifact.js";
20
18
  import { touchGateArtifact } from "../verification/gateArtifactStore.js";
21
19
  import { beginGateVerification, checkResultsFromGateArtifact, checkResultsFromGateJob, gateIdentityForCandidate, lookupReusableGateArtifact, recordGateArtifactFromJob, recordGateArtifactFromStepOutcomes, resolveVerificationGate, runGateStepsInProcess } from "../verification/verificationGateService.js";
@@ -370,12 +368,9 @@ export class GitIntegrationService {
370
368
  throw new Error("Integration changed before cleanup; read its current record.");
371
369
  }
372
370
  integration = current;
373
- const activeJob = this.store.listDurableJobs(integration.taskId).find(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === integration.id
374
- && (job.status === "queued" || job.status === "running"
375
- || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
376
- if (activeJob !== undefined) {
377
- throw new Error(`Integration ${integration.id} has an active DurableJob: ${activeJob.id}/${activeJob.status}.`);
378
- }
371
+ const managedWorkspace = this.store.getIntegrationWorkspace(integration.taskId, integration.id);
372
+ if (managedWorkspace === null)
373
+ return "missing";
379
374
  const task = this.store.getTask(integration.taskId);
380
375
  if (task === null || !task.projectBindings.some(({ projectId }) => projectId === integration.projectId)) {
381
376
  throw new Error(`Integration Task Project is unavailable: ${integration.id}.`);
@@ -387,42 +382,8 @@ export class GitIntegrationService {
387
382
  if (taskRepository === undefined) {
388
383
  throw new Error(`Integration Task clone is unavailable: ${task.id}/${project.id}.`);
389
384
  }
390
- const managedWorkspace = this.store.getIntegrationWorkspace(integration.taskId, integration.id);
391
- if (managedWorkspace !== null) {
392
- const runtime = this.#runtimePreparation(integration, managedWorkspace);
393
- this.runtimeIsolation.cleanup(runtime, integration.status === "committed" ? "completion" : "failure");
394
- }
395
- const recorded = managedWorkspace?.entries[0];
396
- const directory = recorded?.directory
397
- ?? task.projectBindings.find(({ projectId }) => projectId === integration.projectId)?.directory;
398
- if (directory === undefined) {
399
- throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
400
- }
401
- const result = await this.git.removeIntegrationWorktree({
402
- repositoryPath: taskRepository,
403
- container: recorded !== undefined
404
- ? dirname(recorded.path)
405
- : integrationWorkspaceRoot(this.home, task.id, integration.id),
406
- directory,
407
- taskSegment: taskWorkspaceRefSegment(task),
408
- integrationId: integration.id,
409
- discardChanges: integration.status === "failed"
410
- });
411
- if (result !== "dirty") {
412
- if (managedWorkspace !== null) {
413
- this.#resourceRegistrar().markWorkspaceDeleted(managedWorkspace);
414
- }
415
- await rm(integrationCheckDirectory(this.home, task.id, integration.id), {
416
- recursive: true,
417
- force: true
418
- });
419
- this.store.removeManagedWorkspace({
420
- type: "integration-attempt",
421
- taskId: integration.taskId,
422
- integrationAttemptId: integration.id
423
- });
424
- }
425
- return result;
385
+ return await new FileTaskWorkspacePreparer(this.home, this.store, this.git, this.now)
386
+ .cleanupIntegrationWorkspace(task.id, integration.id, { runtimeIsolation: this.runtimeIsolation });
426
387
  }
427
388
  finally {
428
389
  release();
@@ -1024,26 +985,6 @@ function isNodeCode(error, code) {
1024
985
  && "code" in error
1025
986
  && error.code === code;
1026
987
  }
1027
- function defaultIntegrationRuntimeIsolation(home, homeId) {
1028
- const controlHome = resolve(home);
1029
- const runtimeRoot = managedIntegrationRuntimeRoot(controlHome);
1030
- return new FileTaskRuntimeIsolation({
1031
- // The integration check's provider data/cache/tmp live in a dedicated Home
1032
- // partition — the same isolation contract every Task runtime obeys — not in
1033
- // a system-wide `/tmp` root. Only the tmux socket ENDPOINT stays a short
1034
- // `/tmp` path (see `integrationCheckEnvironment`) for the `sockaddr_un`
1035
- // budget; ordinary runtime state is now Yui-managed under Home.
1036
- runtimeRoot,
1037
- pathLayout: "compact",
1038
- controlPlane: {
1039
- yuiHome: controlHome,
1040
- managedRuntimeRoot: runtimeRoot,
1041
- controllerSocketPath: controllerSocketPath(homeId),
1042
- tmuxNamespace: yuiTmuxServerName(controlHome),
1043
- globalInstallPaths: [process.execPath]
1044
- }
1045
- });
1046
- }
1047
988
  /**
1048
989
  * Reuse is enabled only for an immutable installed release. Development
1049
990
  * checkouts have no content-addressed release identity, so they return.
@@ -0,0 +1,67 @@
1
+ import { isAbsolute, join, resolve } from "node:path";
2
+ import { processGenerationIsLive } from "../core/fileLockOwner.js";
3
+ import { scanProcessPathRefs } from "../resources/liveReferences.js";
4
+ import { inspectTaskRuntimeCleanupClaims, parseTaskRuntimeIsolationDescriptor, YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR } from "../runtime/taskRuntimeIsolation.js";
5
+ /** Result uncertainty and physical occupancy are different facts. Reuse the
6
+ * recorded process generation and resource scanner, without rewriting the Job
7
+ * or treating acknowledgement as stop evidence. Every cleanup reads this anew.
8
+ */
9
+ export function jobCleanupBlocker(job, home, phase = "before-removal") {
10
+ if (job.status === "queued" || job.status === "running")
11
+ return `DurableJob is ${job.status}.`;
12
+ if (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined) {
13
+ return "Unknown Job result requires explicit acknowledgement.";
14
+ }
15
+ if (job.status !== "unknown-needs-attention" && job.startedAt === undefined
16
+ && job.process === undefined && job.operation.effect === "none")
17
+ return undefined;
18
+ if (job.process === undefined || home === undefined) {
19
+ return "Job has no exact process/Home evidence for physical inspection.";
20
+ }
21
+ try {
22
+ if (processGenerationIsLive(job.process.pid, job.process.startIdentity)) {
23
+ return "The exact Job runner process is still live.";
24
+ }
25
+ // A reusable idle Role can share this workspace. Its exact-owner stop is
26
+ // allowed only after result/runner checks; path references must then be
27
+ // rechecked before any child deletion or ordinary archive commit.
28
+ if (phase === "before-role-stop")
29
+ return undefined;
30
+ // Steps may outlive a killed runner. Their cwd, logs and isolated runtime
31
+ // paths remain protective references; no process is signalled here.
32
+ const paths = [job.workspace, join(resolve(home), "artifacts", "jobs", job.taskId, job.id),
33
+ ...["TMPDIR", "XDG_DATA_HOME", "XDG_CACHE_HOME"].flatMap(key => {
34
+ const path = job.env[key];
35
+ return path !== undefined && isAbsolute(path) ? [path] : [];
36
+ })];
37
+ const serialized = job.env[YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR];
38
+ if (serialized !== undefined) {
39
+ const descriptor = parseTaskRuntimeIsolationDescriptor(serialized);
40
+ const owner = descriptor.workspace.owner;
41
+ if (descriptor.taskId !== job.taskId || descriptor.workspace.root !== job.workspace
42
+ || owner.type !== job.owner.kind
43
+ || (owner.type === "integration-attempt" && job.owner.kind === "integration-attempt"
44
+ && owner.integrationAttemptId !== job.owner.integrationAttemptId)
45
+ || (owner.type === "work-item" && job.owner.kind === "work-item"
46
+ && owner.workItemId !== job.owner.workItemId)) {
47
+ return "Job runtime descriptor does not match its exact owner/workspace.";
48
+ }
49
+ const claims = inspectTaskRuntimeCleanupClaims(descriptor);
50
+ const uncertain = claims.find(claim => claim.ownership !== "owned" || claim.state !== "inactive");
51
+ if (uncertain !== undefined)
52
+ return `Job runtime cleanup claim is unverified: ${uncertain.id}.`;
53
+ // cwd/fd references follow rename, unlike the recorded environment.
54
+ // Use the same exact claim inventory as deletion, before archive commits.
55
+ paths.push(descriptor.roots.runtime, ...claims.map(claim => claim.id));
56
+ }
57
+ const scan = scanProcessPathRefs(paths);
58
+ if (scan.diagnostics.some(diagnostic => diagnostic.severity === "error")
59
+ || [...scan.refs.values()].some(refs => refs.length > 0)) {
60
+ return "Job workspace/log/runtime references are still live or unverified.";
61
+ }
62
+ }
63
+ catch (error) {
64
+ return `Job physical inspection is unavailable: ${error instanceof Error ? error.message : String(error)}`;
65
+ }
66
+ return undefined;
67
+ }
@@ -821,7 +821,7 @@ export class NodeGitWorkspace {
821
821
  const state = await this.inspectWorktree(input);
822
822
  if (state === "dirty")
823
823
  return state;
824
- const container = await canonicalContainer(input.container, false);
824
+ const container = await cleanupContainer(input.container);
825
825
  const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
826
826
  const path = managedPath(container, input.directory);
827
827
  const project = await this.inspect(input.repositoryPath);
@@ -970,13 +970,13 @@ export class NodeGitWorkspace {
970
970
  return "removed";
971
971
  }
972
972
  async inspectWorktree(input) {
973
- const container = await canonicalContainer(input.container, false);
973
+ const container = await cleanupContainer(input.container);
974
974
  const path = managedPath(container, input.directory);
975
975
  const kind = await pathKind(path);
976
976
  const branch = input.expectedBranch ?? worktreeIdentity(input.taskSegment, input.roleName).branch;
977
977
  if (kind === undefined) {
978
978
  const project = await this.inspect(input.repositoryPath);
979
- await inspectMissingWorktreeRegistration(project.root, path, branch);
979
+ await inspectMissingWorktreeRegistration(project.root, path, branch, input.expectedBranch !== undefined);
980
980
  return "missing";
981
981
  }
982
982
  if (kind === "symlink")
@@ -984,7 +984,7 @@ export class NodeGitWorkspace {
984
984
  const canonicalContainerPath = await canonicalContainer(container, false);
985
985
  const project = await this.inspect(input.repositoryPath);
986
986
  await assertOwnedWorktree(project, canonicalContainerPath, path);
987
- await assertExpectedBranch(path, branch);
987
+ await assertExpectedBranch(path, branch, input.expectedBranch !== undefined);
988
988
  const porcelain = await readWorktreeStatus(path);
989
989
  return porcelain.length > 0 ? "dirty" : "clean";
990
990
  }
@@ -1000,19 +1000,19 @@ export class NodeGitWorkspace {
1000
1000
  });
1001
1001
  }
1002
1002
  async #removeManagedWorktree(input) {
1003
- const container = await canonicalContainer(input.container, false);
1003
+ const container = await cleanupContainer(input.container);
1004
1004
  const path = managedPath(container, input.identity.directory);
1005
1005
  const kind = await pathKind(path);
1006
1006
  if (kind === "symlink")
1007
1007
  throw new Error("Managed worktree path must not be a symbolic link.");
1008
1008
  const repository = await this.inspect(input.repositoryPath);
1009
1009
  if (kind === undefined) {
1010
- await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch);
1010
+ await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch, true);
1011
1011
  }
1012
1012
  if (kind === "directory") {
1013
1013
  const canonicalContainerPath = await canonicalContainer(container, false);
1014
1014
  await assertOwnedWorktree(repository, canonicalContainerPath, path);
1015
- await assertExpectedBranch(path, input.identity.branch);
1015
+ await assertExpectedBranch(path, input.identity.branch, true);
1016
1016
  if (input.discardChanges === true) {
1017
1017
  await git(["-C", repository.root, "worktree", "remove", "--force", "--", path]);
1018
1018
  }
@@ -1043,19 +1043,22 @@ export class NodeGitWorkspace {
1043
1043
  /** Missing paths do not imply missing Git metadata. Never prune another
1044
1044
  * workspace's registration as a side effect of cleaning this exact owner.
1045
1045
  */
1046
- async function removeMissingWorktreeRegistration(repositoryRoot, path, branch) {
1047
- if (!await inspectMissingWorktreeRegistration(repositoryRoot, path, branch))
1046
+ async function removeMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached = false) {
1047
+ if (!await inspectMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached))
1048
1048
  return;
1049
1049
  if (await pathKind(path) !== undefined)
1050
1050
  throw new Error(`Worktree reappeared before metadata cleanup: ${path}.`);
1051
1051
  await git(["-C", repositoryRoot, "worktree", "remove", "--force", "--", path]);
1052
1052
  }
1053
- async function inspectMissingWorktreeRegistration(repositoryRoot, path, branch) {
1053
+ async function inspectMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached = false) {
1054
1054
  const records = (await git(["-C", repositoryRoot, "worktree", "list", "--porcelain", "-z"])).split("\0\0");
1055
1055
  const fields = records.map(record => record.split("\0")).find(record => record.includes(`worktree ${path}`));
1056
1056
  if (fields === undefined)
1057
1057
  return false;
1058
- if (!fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field.startsWith("locked"))) {
1058
+ const pinnedDetached = allowDetached && fields.includes("detached")
1059
+ && await gitSucceeds(["-C", repositoryRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`])
1060
+ && fields.includes(`HEAD ${await resolveRefCommit(repositoryRoot, `refs/heads/${branch}`)}`);
1061
+ if ((!fields.includes(`branch refs/heads/${branch}`) && !pinnedDetached) || fields.some(field => field.startsWith("locked"))) {
1059
1062
  cleanupFailure("git-registration-mismatch", "Missing worktree has uncertain or locked Git ownership; metadata retained.", { branch, locked: false }, { branch: fields.find(field => field.startsWith("branch "))?.slice(7) ?? null,
1060
1063
  locked: fields.some(field => field.startsWith("locked")) });
1061
1064
  }
@@ -1115,7 +1118,12 @@ async function assertExpectedBranch(path, expected, allowRebase = false) {
1115
1118
  return;
1116
1119
  throw new Error(`Managed worktree is rebasing an unexpected branch: ${headName}.`);
1117
1120
  }
1118
- throw new Error("Managed worktree has detached HEAD without its expected rebase.");
1121
+ // A terminal Integration can legitimately retain a detached checkout.
1122
+ // Its exact owned path/common-dir were checked by the caller; require its
1123
+ // retained branch to pin the same commit before allowing clean removal.
1124
+ if (await resolveRefCommit(path, "HEAD") === await resolveRefCommit(path, `refs/heads/${expected}`))
1125
+ return;
1126
+ cleanupFailure("workspace-identity-mismatch", "Detached worktree is not pinned by its owned branch or rebase.", expected, "unproven detached HEAD");
1119
1127
  }
1120
1128
  const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
1121
1129
  if (branch !== expected) {
@@ -1237,6 +1245,27 @@ async function canonicalContainer(path, create) {
1237
1245
  // stay rejected by the managed-path lstat checks.
1238
1246
  return canonicalDirectory(lexical, "Worktree container");
1239
1247
  }
1248
+ /** A previous removal may have deleted the whole owner container before its
1249
+ * receipt committed. Resolve only surviving ancestors, without creating it.
1250
+ * Git registration inspection remains mandatory even when this path is absent.
1251
+ */
1252
+ async function cleanupContainer(path) {
1253
+ const lexical = resolve(path);
1254
+ if (await pathKind(lexical) === "symlink") {
1255
+ cleanupFailure("workspace-identity-mismatch", "Cleanup container must not be a symbolic link.", "owned directory or missing path", "symbolic link");
1256
+ }
1257
+ try {
1258
+ return await canonicalContainer(lexical, false);
1259
+ }
1260
+ catch (error) {
1261
+ if (!isErrno(error, "ENOENT"))
1262
+ throw error;
1263
+ const parent = dirname(lexical);
1264
+ if (parent === lexical)
1265
+ throw error;
1266
+ return join(await cleanupContainer(parent), relative(parent, lexical));
1267
+ }
1268
+ }
1240
1269
  async function canonicalDirectory(path, label) {
1241
1270
  const value = requireText(path, label);
1242
1271
  const canonical = await realpath(isAbsolute(value) ? value : resolve(value));