@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.
- package/README.md +4 -2
- package/dist/cli/commandCatalog.js +8 -3
- package/dist/cli/interactionPolicy.js +4 -0
- package/dist/commands/taskCommands.js +5 -7
- package/dist/commands/taskIntegrationCommands.js +5 -16
- package/dist/controlPlaneCli.js +28 -21
- package/dist/controller/clientRuntime.js +3 -2
- package/dist/integration/gitIntegrationService.js +8 -67
- package/dist/job/jobCleanupInspection.js +67 -0
- 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/web/assets/client/api.js +8 -0
- package/dist/web/assets/client/app.js +10 -6
- 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 +178 -62
- package/dist/web/assets/client/overview.js +9 -13
- package/dist/web/assets/client/records.js +142 -7
- package/dist/web/assets/client/sections.js +234 -198
- package/dist/web/assets/icons.js +4 -0
- package/dist/web/assets/styles/components.js +64 -17
- package/dist/web/assets/styles/responsive.js +7 -5
- package/dist/web/assets/styles/tokens.js +1 -1
- package/dist/web/assets/styles/views.js +48 -31
- package/dist/web/webServer.js +12 -1
- package/dist/web/webTaskSurface.js +8 -1
- package/docs/observability/README.md +13 -3
- package/docs/observability/README.zh-CN.md +10 -3
- package/docs/release-workflow.md +25 -0
- package/docs/release-workflow.zh-CN.md +19 -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/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 +3 -0
- package/skills/yui-runtime/SKILL.md +4 -0
- package/skills/yui-runtime/references/publication.md +20 -4
- 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
|
|
166
|
-
|
|
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
|
|
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: "
|
|
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 =
|
|
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",
|
|
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
|
-
//
|
|
53
|
-
//
|
|
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
|
|
74
|
-
: `Integration worktree
|
|
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) {
|
package/dist/controlPlaneCli.js
CHANGED
|
@@ -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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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.
|
|
529
|
-
.filter((job) => job.
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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));
|