@zq-silk/yui 1.0.1 → 1.1.2
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 +27 -9
- package/dist/cli/commandCatalog.js +7 -5
- package/dist/cli/invocationAuthority.js +4 -0
- package/dist/cli.js +130 -5
- package/dist/commands/releaseCommands.js +33 -24
- package/dist/commands/taskCommands.js +16 -25
- package/dist/commands/taskFactCommands.js +6 -2
- package/dist/controller/clientRuntime.js +7 -2
- package/dist/controller/domainIdentity.js +7 -7
- package/dist/controller/handoverCandidate.js +2 -5
- package/dist/controller/jobSupervisor.js +4 -4
- package/dist/controller/resourceCleanupLinux.js +35 -19
- package/dist/controller/resourceInventoryLinux.js +104 -7
- package/dist/controller/runtime.js +4 -13
- package/dist/controller/runtimeLaunchCoordinator.js +2 -4
- package/dist/controller/sessionOwnerReconciliation.js +40 -16
- package/dist/core/controllerClient.js +44 -16
- package/dist/core/controllerEndpoint.js +4 -3
- package/dist/core/controllerProcessIdentity.js +54 -0
- package/dist/core/controllerServer.js +16 -11
- package/dist/core/fileLockOwner.js +53 -12
- package/dist/doctor/doctor.js +232 -12
- package/dist/doctor/ptyProbe.js +74 -0
- package/dist/doctor/ptyProbeChild.js +90 -0
- package/dist/execution/workItemMainRun.js +1 -1
- package/dist/executor/agentAdapter.js +0 -8
- package/dist/executor/agentExecutor.js +4 -1
- package/dist/executor/fileRoleLaunchPlanner.js +3 -22
- package/dist/message/messageContinuation.js +1 -1
- package/dist/release/releaseHandover.js +77 -25
- package/dist/release/runtimeRelease.js +7 -7
- package/dist/repository/gitWorkspace.js +9 -8
- package/dist/repository/taskWorkspacePreparer.js +15 -57
- package/dist/resources/liveReferences.js +64 -7
- package/dist/runtime/agentEndpointIdentity.js +4 -1
- package/dist/runtime/agentHost.js +8 -9
- package/dist/runtime/codexInteractiveHost.js +1 -1
- package/dist/runtime/codexSharedDaemon.js +211 -0
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/jsonLineChannel.js +6 -1
- package/dist/runtime/localStartup.js +65 -0
- package/dist/runtime/native/darwin-arm64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-arm64/process-identity +0 -0
- package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-x64/process-identity +0 -0
- package/dist/runtime/{claude-process-owner → native/linux-x64/claude-process-owner} +0 -0
- package/dist/runtime/nativeExecutable.js +10 -0
- package/dist/runtime/runtimeCoherence.js +10 -3
- package/dist/runtime/sessionOwnerIdentity.js +19 -0
- package/dist/runtime/sessionTerminationGuard.js +10 -3
- package/dist/runtime/structuredProviderHost.js +51 -26
- package/dist/scheduler/activeRoleRunDelivery.js +2 -11
- package/dist/scheduler/leaderWakeupProcessor.js +1 -2
- package/dist/scheduler/ports.js +1 -8
- package/dist/scheduler/taskExecutionProjection.js +1 -6
- package/dist/storage/sqliteSchema.js +15 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskCatalog.js +10 -2
- package/dist/storage/upgrade/taskMainWorkspaceMigration.js +83 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +25 -6
- package/dist/task/task.js +0 -12
- package/dist/tmux/tmuxManager.js +8 -2
- package/dist/tmux/tmuxSocketEndpoint.js +23 -1
- package/dist/web/assets/client/app.js +131 -11
- package/dist/web/assets/client/i18n.js +18 -0
- package/dist/web/assets/client/taskSummary.js +7 -2
- package/dist/web/assets/client/taskSurface.js +54 -5
- package/dist/web/assets/client/view.js +7 -2
- package/dist/web/assets/shell.js +21 -11
- package/dist/web/assets/styles/layout.js +33 -6
- package/dist/web/assets/styles/responsive.js +18 -5
- package/dist/web/assets/styles/tokens.js +1 -1
- package/dist/web/assets/styles/widgets.js +5 -0
- package/docs/provider-runtime.md +7 -0
- package/docs/provider-runtime.zh-CN.md +5 -0
- package/docs/release-workflow.md +7 -1
- package/docs/task-delivery.md +5 -4
- package/docs/testing/verification-levels.md +19 -7
- package/docs/testing/verification-levels.zh-CN.md +8 -0
- package/i18n/README.zh-CN.md +19 -6
- package/package.json +7 -8
- package/skills/yui-leader/SKILL.md +8 -0
- package/skills/yui-leader/references/execution.md +4 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { accessSync, constants, statSync, writeSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const entry = process.argv[2];
|
|
6
|
+
const timeoutMs = Number(process.argv[3]);
|
|
7
|
+
const report = (check) => writeSync(1, `${JSON.stringify(check)}\n`);
|
|
8
|
+
const errorDetail = (error) => error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
9
|
+
async function probe() {
|
|
10
|
+
let pty;
|
|
11
|
+
try {
|
|
12
|
+
pty = require(entry);
|
|
13
|
+
if (typeof pty.spawn !== "function")
|
|
14
|
+
throw new Error("node-pty does not expose spawn");
|
|
15
|
+
report({ name: "node-pty", status: "ok", detail: "native module loaded" });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
report({
|
|
19
|
+
name: "node-pty", status: "invalid",
|
|
20
|
+
detail: `native load failed: ${errorDetail(error)}. If installation reported blocked lifecycle scripts, review npm allow-scripts separately; no install files were changed.`
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (process.platform === "darwin") {
|
|
25
|
+
// The pinned node-pty selects build/Release, build/Debug or prebuilds.
|
|
26
|
+
// Inspect the module it actually loaded, not the first helper that exists.
|
|
27
|
+
const native = Object.keys(require.cache).find(path => basename(path) === "pty.node");
|
|
28
|
+
if (!native) {
|
|
29
|
+
report({ name: "pty helper", status: "invalid", detail: "Cannot identify the loaded pty.node; helper path is unverified." });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const helper = join(dirname(native), "spawn-helper");
|
|
33
|
+
let mode = "unknown";
|
|
34
|
+
try {
|
|
35
|
+
mode = (statSync(helper).mode & 0o7777).toString(8).padStart(4, "0");
|
|
36
|
+
accessSync(helper, constants.X_OK);
|
|
37
|
+
report({ name: "pty helper", status: "ok", detail: `helper=${helper}; mode=${mode}; executable by current user` });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
const code = error.code;
|
|
41
|
+
report({
|
|
42
|
+
name: "pty helper", status: code === "ENOENT" ? "missing" : "invalid",
|
|
43
|
+
detail: `helper=${helper}; mode=${mode}; ${code === "EACCES" ? "permission denied for current user" : "helper check failed"}: ${errorDetail(error)}`
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await new Promise(resolve => {
|
|
49
|
+
const marker = "yui-pty-probe-ok";
|
|
50
|
+
let terminal;
|
|
51
|
+
let output = "";
|
|
52
|
+
let finished = false;
|
|
53
|
+
const finish = (status, detail, kill = false) => {
|
|
54
|
+
if (finished)
|
|
55
|
+
return;
|
|
56
|
+
finished = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (kill && terminal) {
|
|
59
|
+
try {
|
|
60
|
+
terminal.kill("SIGKILL");
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
detail += `; cleanup: ${errorDetail(error)}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
report({ name: "pty spawn", status, detail });
|
|
67
|
+
resolve();
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(() => finish("invalid", `timeout after ${timeoutMs}ms waiting for output and normal exit`, true), timeoutMs);
|
|
70
|
+
try {
|
|
71
|
+
// Absolute, fixed executable; no shell, Node preloads, Agent or account access.
|
|
72
|
+
terminal = pty.spawn("/bin/echo", [marker], {
|
|
73
|
+
name: "xterm", cols: 80, rows: 24, cwd: "/",
|
|
74
|
+
env: { PATH: "/usr/bin:/bin", LANG: "C" }
|
|
75
|
+
});
|
|
76
|
+
terminal.onData(data => { output = (output + data).slice(-4096); });
|
|
77
|
+
terminal.onExit(({ exitCode, signal }) => {
|
|
78
|
+
const ok = exitCode === 0 && !signal && output.replace(/\r/g, "").trim() === marker;
|
|
79
|
+
finish(ok ? "ok" : "invalid", `${ok ? "expected output and normal exit confirmed (PTY dependency only; Web Terminal and Provider protocols unverified)" : "unexpected output or exit"}; exit=${exitCode}; signal=${signal ?? 0}; output=${JSON.stringify(output)}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
finish("invalid", `spawn failed: ${errorDetail(error)}`, true);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
await probe();
|
|
88
|
+
// node-pty's native watcher can retain handles; the fixed child has exited, or
|
|
89
|
+
// was explicitly killed on failure. The parent also bounds native hangs/aborts.
|
|
90
|
+
process.exit(0);
|
|
@@ -61,7 +61,7 @@ export function dispatchWorkItemSynthesis(store, taskId, workItemId, sourceRunId
|
|
|
61
61
|
if (store.getActiveRun(taskId, role.name) !== null) {
|
|
62
62
|
throw new Error(`WorkItem main Role already has an active AgentRun: ${role.name}.`);
|
|
63
63
|
}
|
|
64
|
-
const workspace = role.name === "leader"
|
|
64
|
+
const workspace = role.name === "leader" || task.projectBindings.length === 0
|
|
65
65
|
? store.getTaskWorkspace(taskId)
|
|
66
66
|
: store.getWorkItemWorkspace(taskId, item.id);
|
|
67
67
|
if (workspace === null) {
|
|
@@ -9,9 +9,6 @@ import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
|
|
|
9
9
|
import { CODEX_SANDBOXES as SANDBOXES, CODEX_APPROVALS as APPROVALS, configurationFieldsFromHelp, configurationFieldWarnings, staticAgentConfigurationFields, STATIC_CONFIGURATION_NOTICE } from "./agentConfigurationFields.js";
|
|
10
10
|
const PROBE_TIMEOUT_MS = 2_000;
|
|
11
11
|
const PROBE_MAX_BYTES = 1024 * 1024;
|
|
12
|
-
// Offline producer/type evidence, not a claim of live account/model testing.
|
|
13
|
-
// Keep the existing minimum support floors separate: see provider-protocol-contracts.md.
|
|
14
|
-
const AUDITED_PRODUCER_VERSIONS = { codex: "0.153.4", claude: "2.1.270" };
|
|
15
12
|
class BaseAdapter {
|
|
16
13
|
launchContextArgs(_input) {
|
|
17
14
|
return [];
|
|
@@ -605,11 +602,6 @@ export function inspectAgentCapabilities(agent, optionsOrNow = {}) {
|
|
|
605
602
|
probedAt: at
|
|
606
603
|
}, fields, at, warnings);
|
|
607
604
|
}
|
|
608
|
-
const audited = adapter.id === "acp" ? undefined : AUDITED_PRODUCER_VERSIONS[adapter.id];
|
|
609
|
-
if (audited !== undefined && compareVersions(version, audited) > 0) {
|
|
610
|
-
warnings.push(`Installed ${adapter.label} version ${version} is newer than the latest audited producer `
|
|
611
|
-
+ `${audited}; required CLI flags were detected, not live protocol compatibility.`);
|
|
612
|
-
}
|
|
613
605
|
return snapshot(agent, adapter, {
|
|
614
606
|
status: "installed", command: agent.command, version, probedAt: at
|
|
615
607
|
}, fields, at, warnings);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { hasRecentTurnId, rememberRecentTurnId, validateRecentTurnIds } from "../runtime/recentTurnIds.js";
|
|
3
3
|
import { roleSessionMayContinue, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
|
|
4
|
-
import { validateProviderRuntimeBinding, currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
|
|
4
|
+
import { assertProviderConversationReplaceable, validateProviderRuntimeBinding, currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
|
|
5
5
|
import { builtinDriverIdForAdapter, builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
|
|
6
6
|
import { builtinAgentEndpointImplementation, validateAgentEndpointImplementation } from "../runtime/agentEndpointIdentity.js";
|
|
7
7
|
/** Recovery addresses retained execution identity, not a live Session grant.
|
|
@@ -212,6 +212,9 @@ export function retireTaskRoleSessionsForWorkspace(set, now) {
|
|
|
212
212
|
if (live !== undefined) {
|
|
213
213
|
throw new Error(`Task Role session must be stopped before workspace migration: ${live.agentId}.`);
|
|
214
214
|
}
|
|
215
|
+
if (set.providerBinding !== null) {
|
|
216
|
+
assertProviderConversationReplaceable(set.providerBinding);
|
|
217
|
+
}
|
|
215
218
|
const timestamp = now.toISOString();
|
|
216
219
|
return validateRoleSessionSet({
|
|
217
220
|
...set,
|
|
@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { isDeepStrictEqual } from "node:util";
|
|
7
7
|
import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
|
|
8
8
|
import { assertExecutionEnvironmentCurrent } from "../runtime/executionEnvironment.js";
|
|
9
|
-
import { taskOwnsManagedWorkspace } from "../task/task.js";
|
|
10
9
|
import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
|
|
11
10
|
import { NATIVE_AGENT_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment } from "../agent/launchEnvironment.js";
|
|
12
11
|
import { compileRoleSessionContext, roleSessionKind } from "../context/roleSessionContext.js";
|
|
@@ -114,30 +113,14 @@ export class FileRoleLaunchPlanner {
|
|
|
114
113
|
if (!runPurposeAdmitsTaskState(purpose, task)) {
|
|
115
114
|
throw new Error(`Task execution is not enabled: ${input.taskId}.`);
|
|
116
115
|
}
|
|
117
|
-
//
|
|
118
|
-
// conversation, and a Task activated with an empty environment plan. Both
|
|
119
|
-
// run with no Project entries, so there is nothing to preflight and no
|
|
120
|
-
// worktree to wait for. Every other fence — live Session, replaceable
|
|
121
|
-
// Conversation, configured Agent, Context protocol identity, adopted
|
|
122
|
-
// execution environment — still applies.
|
|
116
|
+
// Draft planning has no delivery workspace. Active Tasks always have one.
|
|
123
117
|
const planningDraft = purpose === "planning" && task.status === "draft";
|
|
124
118
|
// A planning cwd is a disposable per-Task runtime resource, not a delivery
|
|
125
119
|
// workspace. Materialize only the exact directory selected at creation.
|
|
126
120
|
if (planningDraft && resolve(role.workspace) === resolve(planningRuntimeCwd(this.home, task.id))) {
|
|
127
121
|
mkdirSync(role.workspace, { recursive: true, mode: 0o700 });
|
|
128
122
|
}
|
|
129
|
-
const workspaceFree = planningDraft
|
|
130
|
-
// An empty resource plan is a legal Task shape, but it does not make a
|
|
131
|
-
// shared directory a legal cwd. Once such a Task is active it can name the
|
|
132
|
-
// directory it means through the existing environment plan, so require that
|
|
133
|
-
// instead of silently running in whatever workspace the Role inherited with
|
|
134
|
-
// isolation skipped.
|
|
135
|
-
if (workspaceFree && !planningDraft && role.executionEnvironment === undefined) {
|
|
136
|
-
throw new Error(`Task ${task.id} owns no workspace and no adopted execution environment, so Role `
|
|
137
|
-
+ `${role.name} has no directory of its own to run in. Request activation with a `
|
|
138
|
-
+ "`scratch` or `local` environment plan, or bind an adopted environment with "
|
|
139
|
-
+ "`environment.bind`, instead of inheriting a shared workspace.");
|
|
140
|
-
}
|
|
123
|
+
const workspaceFree = planningDraft;
|
|
141
124
|
const runWorkspace = activeRun?.workspace;
|
|
142
125
|
const main = this.store.getTaskWorkspace(task.id);
|
|
143
126
|
// Quick Win (EXE-04/EXE-08): classify workspace preflight failures so
|
|
@@ -694,9 +677,7 @@ export class FileRoleLaunchPlanner {
|
|
|
694
677
|
}
|
|
695
678
|
}
|
|
696
679
|
export function nativeAgentWorkspace(workspace) {
|
|
697
|
-
return workspace.
|
|
698
|
-
? workspace.entries[0].path
|
|
699
|
-
: workspace.root;
|
|
680
|
+
return workspace.root;
|
|
700
681
|
}
|
|
701
682
|
export function nativeAdditionalDirectories(workspace, agentWorkspace) {
|
|
702
683
|
return [workspace.root, ...workspace.entries.map(({ path }) => path)]
|
|
@@ -56,7 +56,7 @@ export function messageContinuationBlocker(store, message) {
|
|
|
56
56
|
if (target.reviewRoundId === undefined && !isDeepStrictEqual([...work.writeProjectIds].sort(), [...owner.effective.writeProjectIds].sort()))
|
|
57
57
|
return "assignment-scope-changed";
|
|
58
58
|
if (target.reviewRoundId === undefined && owner.workspace !== undefined) {
|
|
59
|
-
const workspace = target.roleName === "leader"
|
|
59
|
+
const workspace = target.roleName === "leader" || task.projectBindings.length === 0
|
|
60
60
|
? store.getTaskWorkspace(message.taskId) : store.getWorkItemWorkspace(message.taskId, target.workItemId);
|
|
61
61
|
const identity = (entries) => entries.map((entry) => ({
|
|
62
62
|
projectId: entry.projectId, path: entry.path, access: entry.access, branch: entry.branch
|
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
* the candidate read-only and reports dual-owner; a crashed activator resumes
|
|
14
14
|
* from the recorded phase.
|
|
15
15
|
*/
|
|
16
|
+
import { processGenerationIsLive } from "../core/fileLockOwner.js";
|
|
16
17
|
import { RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
|
|
17
|
-
import { acquireHandoverLock, isOwnerLive, newHandoverId, readActiveReleasePointer, readCandidateDiscovery, readHandoverFence, readHandoverReceipt, readRuntimeIdentity, removeCandidateDiscovery, removeHandoverFence, writeActiveReleasePointer, writeHandoverFence, writeHandoverReceipt } from "./runtimeRelease.js";
|
|
18
|
+
import { acquireHandoverLock, isOwnerLive, newHandoverId, readActiveReleasePointer, readCandidateDiscovery, readHandoverFence, readHandoverReceipt, readRuntimeIdentity, removeActiveReleasePointer, removeCandidateDiscovery, removeHandoverFence, writeActiveReleasePointer, writeHandoverFence, writeHandoverReceipt } from "./runtimeRelease.js";
|
|
18
19
|
export const DEFAULT_CANDIDATE_READY_TIMEOUT_MS = 30_000;
|
|
19
20
|
export const DEFAULT_PROMOTION_TIMEOUT_MS = RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS;
|
|
20
21
|
export const DEFAULT_POLL_INTERVAL_MS = 100;
|
|
22
|
+
export class ControllerStartUnconfirmedError extends Error {
|
|
23
|
+
pid;
|
|
24
|
+
constructor(pid, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.pid = pid;
|
|
27
|
+
this.name = "ControllerStartUnconfirmedError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
/**
|
|
22
31
|
* Optional confirmation debounce after the candidate latches `dualOwner:
|
|
23
32
|
* true`. Defaults to 0: the candidate's own exit grace
|
|
@@ -147,13 +156,17 @@ async function activateLocked(ports, locked) {
|
|
|
147
156
|
ports.spawnCandidate(home, releaseDir, handoverId);
|
|
148
157
|
const candidateReady = await waitForCandidateReady(ports, home, manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);
|
|
149
158
|
if (candidateReady === null) {
|
|
150
|
-
await rollbackHandover(ports, home, fence
|
|
159
|
+
const rollbackError = await rollbackHandover(ports, home, fence);
|
|
151
160
|
return {
|
|
152
161
|
outcome: "aborted",
|
|
153
162
|
phase: "candidate-ready",
|
|
154
|
-
message:
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
message: rollbackError === null
|
|
164
|
+
? "The new Controller candidate did not become ready in time."
|
|
165
|
+
: `The new Controller candidate did not become ready; rollback is unconfirmed: ${rollbackError}`,
|
|
166
|
+
action: rollbackError === null
|
|
167
|
+
? "The old Controller has resumed accepting mutations; retry the activation."
|
|
168
|
+
: "Preserve the handover fence and candidate discovery; inspect process owners before retrying.",
|
|
169
|
+
recoverable: rollbackError === null
|
|
157
170
|
};
|
|
158
171
|
}
|
|
159
172
|
// 6) Switch the active release pointer. This is the atomic commit point:
|
|
@@ -221,10 +234,21 @@ async function activateWithoutOldController(ports, home, manifest, releaseDir, r
|
|
|
221
234
|
await ports.startControllerFromRelease(home, releaseDir);
|
|
222
235
|
}
|
|
223
236
|
catch (error) {
|
|
224
|
-
|
|
225
|
-
|
|
237
|
+
if (error instanceof ControllerStartUnconfirmedError) {
|
|
238
|
+
return {
|
|
239
|
+
outcome: "aborted",
|
|
240
|
+
phase: "start-controller",
|
|
241
|
+
message: `New Controller startup is unconfirmed (PID ${error.pid}): ${error.message}`,
|
|
242
|
+
action: "Preserve the new release pointer and inspect the exact Controller process before retrying.",
|
|
243
|
+
recoverable: false
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// A pre-spawn failure cannot have started a Controller. Restore the
|
|
247
|
+
// pointer's previous value, including its absence.
|
|
226
248
|
if (active !== null)
|
|
227
249
|
writeActiveReleasePointer(home, active);
|
|
250
|
+
else
|
|
251
|
+
removeActiveReleasePointer(home);
|
|
228
252
|
return {
|
|
229
253
|
outcome: "aborted",
|
|
230
254
|
phase: "start-controller",
|
|
@@ -252,7 +276,7 @@ async function readOldController(ports, home) {
|
|
|
252
276
|
if (typeof identity !== "object"
|
|
253
277
|
|| identity === null
|
|
254
278
|
|| typeof identity.pid !== "number") {
|
|
255
|
-
|
|
279
|
+
throw new Error("Running Controller returned an invalid identity; handover cannot be fenced.");
|
|
256
280
|
}
|
|
257
281
|
const record = identity;
|
|
258
282
|
if (record.releaseDrifted === true) {
|
|
@@ -262,8 +286,9 @@ async function readOldController(ports, home) {
|
|
|
262
286
|
const startIdentity = typeof record.processStartIdentity === "string"
|
|
263
287
|
? record.processStartIdentity
|
|
264
288
|
: undefined;
|
|
265
|
-
if (startIdentity === undefined)
|
|
266
|
-
|
|
289
|
+
if (startIdentity === undefined) {
|
|
290
|
+
throw new Error("Running Controller did not provide a process-generation identity; handover cannot be fenced.");
|
|
291
|
+
}
|
|
267
292
|
return Object.freeze({
|
|
268
293
|
pid: record.pid,
|
|
269
294
|
processStartIdentity: startIdentity,
|
|
@@ -327,19 +352,29 @@ async function waitForPromotion(ports, home, fence, timeoutMs, pollIntervalMs, d
|
|
|
327
352
|
await ports.sleep(pollIntervalMs);
|
|
328
353
|
}
|
|
329
354
|
}
|
|
330
|
-
async function rollbackHandover(ports, home, fence
|
|
355
|
+
async function rollbackHandover(ports, home, fence) {
|
|
331
356
|
const candidate = readCandidateDiscovery(home);
|
|
332
357
|
if (candidate !== null) {
|
|
333
358
|
try {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
359
|
+
if (processGenerationIsLive(candidate.pid, candidate.processStartIdentity)) {
|
|
360
|
+
ports.killOwnedProcess({
|
|
361
|
+
pid: candidate.pid,
|
|
362
|
+
processStartIdentity: candidate.processStartIdentity,
|
|
363
|
+
buildId: "candidate",
|
|
364
|
+
version: "candidate"
|
|
365
|
+
});
|
|
366
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
367
|
+
if (!processGenerationIsLive(candidate.pid, candidate.processStartIdentity))
|
|
368
|
+
break;
|
|
369
|
+
await ports.sleep(50);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (processGenerationIsLive(candidate.pid, candidate.processStartIdentity)) {
|
|
373
|
+
return `candidate PID ${candidate.pid} is still live`;
|
|
374
|
+
}
|
|
340
375
|
}
|
|
341
|
-
catch {
|
|
342
|
-
|
|
376
|
+
catch (error) {
|
|
377
|
+
return `candidate PID ${candidate.pid}: ${messageOf(error)}`;
|
|
343
378
|
}
|
|
344
379
|
removeCandidateDiscovery(home);
|
|
345
380
|
}
|
|
@@ -348,8 +383,8 @@ async function rollbackHandover(ports, home, fence, reason) {
|
|
|
348
383
|
handoverId: fence.handoverId
|
|
349
384
|
});
|
|
350
385
|
}
|
|
351
|
-
catch {
|
|
352
|
-
|
|
386
|
+
catch (error) {
|
|
387
|
+
return `old Controller rollback: ${messageOf(error)}`;
|
|
353
388
|
}
|
|
354
389
|
writeHandoverFence(home, Object.freeze({
|
|
355
390
|
...fence,
|
|
@@ -367,7 +402,7 @@ async function rollbackHandover(ports, home, fence, reason) {
|
|
|
367
402
|
startedAt: fence.createdAt,
|
|
368
403
|
completedAt: new Date().toISOString()
|
|
369
404
|
}));
|
|
370
|
-
|
|
405
|
+
return null;
|
|
371
406
|
}
|
|
372
407
|
async function recoverInterruptedHandover(ports, home, locked) {
|
|
373
408
|
const fence = readHandoverFence(home);
|
|
@@ -433,7 +468,15 @@ async function recoverInterruptedHandover(ports, home, locked) {
|
|
|
433
468
|
// candidate-ready wait.
|
|
434
469
|
const candidateReady = await waitForCandidateReady(ports, home, locked.manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);
|
|
435
470
|
if (candidateReady === null) {
|
|
436
|
-
await rollbackHandover(ports, home, fence
|
|
471
|
+
const rollbackError = await rollbackHandover(ports, home, fence);
|
|
472
|
+
if (rollbackError !== null) {
|
|
473
|
+
return {
|
|
474
|
+
outcome: "aborted", phase: "recovery",
|
|
475
|
+
message: `Interrupted handover rollback is unconfirmed: ${rollbackError}`,
|
|
476
|
+
action: "Preserve the handover fence and candidate discovery; inspect process owners before retrying.",
|
|
477
|
+
recoverable: false
|
|
478
|
+
};
|
|
479
|
+
}
|
|
437
480
|
return null;
|
|
438
481
|
}
|
|
439
482
|
const pointer = Object.freeze({
|
|
@@ -472,10 +515,19 @@ async function recoverInterruptedHandover(ports, home, locked) {
|
|
|
472
515
|
handoverId: fence.handoverId
|
|
473
516
|
});
|
|
474
517
|
}
|
|
475
|
-
catch {
|
|
476
|
-
|
|
518
|
+
catch (error) {
|
|
519
|
+
return {
|
|
520
|
+
outcome: "aborted", phase: "recovery",
|
|
521
|
+
message: `Old Controller rollback is unconfirmed: ${messageOf(error)}`,
|
|
522
|
+
action: "Preserve the handover fence; inspect the old Controller before retrying.",
|
|
523
|
+
recoverable: false
|
|
524
|
+
};
|
|
477
525
|
}
|
|
478
526
|
}
|
|
527
|
+
// The exact candidate generation is gone; only now can its stale
|
|
528
|
+
// discovery be removed during interrupted-handover recovery.
|
|
529
|
+
if (readCandidateDiscovery(home) !== null)
|
|
530
|
+
removeCandidateDiscovery(home);
|
|
479
531
|
removeHandoverFence(home);
|
|
480
532
|
return null;
|
|
481
533
|
}
|
|
@@ -18,8 +18,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
18
18
|
import { isStorageVersion, compareStorageVersions } from "../storage/storageVersions.js";
|
|
19
19
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
|
-
import {
|
|
22
|
-
import { processGenerationIsLive } from "../core/fileLockOwner.js";
|
|
21
|
+
import { currentProcessStartIdentity, processGenerationIsLive } from "../core/fileLockOwner.js";
|
|
23
22
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
24
23
|
export const RELEASES_DIRECTORY = "runtime/releases";
|
|
25
24
|
export const ACTIVE_RELEASE_POINTER_PATH = "runtime/active-release.json";
|
|
@@ -132,6 +131,10 @@ export function writeActiveReleasePointer(home, pointer) {
|
|
|
132
131
|
validatePointer(pointer);
|
|
133
132
|
writeTextFileAtomically(join(resolve(home), ACTIVE_RELEASE_POINTER_PATH), `${JSON.stringify(pointer, null, 2)}\n`);
|
|
134
133
|
}
|
|
134
|
+
/** Restore the absence of a pointer after a failed first activation. */
|
|
135
|
+
export function removeActiveReleasePointer(home) {
|
|
136
|
+
rmSync(join(resolve(home), ACTIVE_RELEASE_POINTER_PATH), { force: true });
|
|
137
|
+
}
|
|
135
138
|
/**
|
|
136
139
|
* Resolves the Home's active release. Returns null when no pointer exists
|
|
137
140
|
* (no immutable release selected). Fails closed when the pointer names a release that is
|
|
@@ -227,7 +230,7 @@ export function writeHandoverReceipt(home, receipt) {
|
|
|
227
230
|
}
|
|
228
231
|
/** True when the exact process (PID + start identity) is still alive. */
|
|
229
232
|
export function isOwnerLive(owner) {
|
|
230
|
-
return
|
|
233
|
+
return processGenerationIsLive(owner.pid, owner.processStartIdentity);
|
|
231
234
|
}
|
|
232
235
|
export function readCandidateDiscovery(home) {
|
|
233
236
|
const candidatePath = join(resolve(home), CANDIDATE_DISCOVERY_PATH);
|
|
@@ -292,10 +295,7 @@ export function isForeignHandoverLockHeld(home, allowedOwnerPid = process.pid) {
|
|
|
292
295
|
export function acquireHandoverLock(home) {
|
|
293
296
|
const lockPath = join(resolve(home), "runtime", "handover.lock");
|
|
294
297
|
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
295
|
-
const startIdentity =
|
|
296
|
-
if (startIdentity === undefined) {
|
|
297
|
-
throw new Error(`Cannot read Linux process start identity for activator PID ${process.pid}.`);
|
|
298
|
-
}
|
|
298
|
+
const startIdentity = currentProcessStartIdentity();
|
|
299
299
|
const owner = Object.freeze({
|
|
300
300
|
pid: process.pid,
|
|
301
301
|
processStartIdentity: startIdentity,
|
|
@@ -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 =
|
|
824
|
+
const container = await canonicalContainer(input.container, false);
|
|
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,7 +970,7 @@ export class NodeGitWorkspace {
|
|
|
970
970
|
return "removed";
|
|
971
971
|
}
|
|
972
972
|
async inspectWorktree(input) {
|
|
973
|
-
const container =
|
|
973
|
+
const container = await canonicalContainer(input.container, false);
|
|
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;
|
|
@@ -1000,7 +1000,7 @@ export class NodeGitWorkspace {
|
|
|
1000
1000
|
});
|
|
1001
1001
|
}
|
|
1002
1002
|
async #removeManagedWorktree(input) {
|
|
1003
|
-
const container =
|
|
1003
|
+
const container = await canonicalContainer(input.container, false);
|
|
1004
1004
|
const path = managedPath(container, input.identity.directory);
|
|
1005
1005
|
const kind = await pathKind(path);
|
|
1006
1006
|
if (kind === "symlink")
|
|
@@ -1136,7 +1136,7 @@ async function assertOwnedWorktree(project, container, path) {
|
|
|
1136
1136
|
}
|
|
1137
1137
|
}
|
|
1138
1138
|
async function inspectExactRecordedWorktree(input) {
|
|
1139
|
-
const container =
|
|
1139
|
+
const container = await canonicalContainer(input.container, false);
|
|
1140
1140
|
const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1141
1141
|
const expectedPath = managedPath(container, input.directory);
|
|
1142
1142
|
if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
|
|
@@ -1231,10 +1231,11 @@ async function canonicalContainer(path, create) {
|
|
|
1231
1231
|
const lexical = resolve(path);
|
|
1232
1232
|
if (create)
|
|
1233
1233
|
await mkdir(lexical, { recursive: true, mode: 0o700 });
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1234
|
+
// Resolve once into the physical path used for every containment and
|
|
1235
|
+
// identity comparison below. System-owned prefix symlinks are normal on
|
|
1236
|
+
// macOS (e.g. /var -> /private/var); symlinks at or below the container
|
|
1237
|
+
// stay rejected by the managed-path lstat checks.
|
|
1238
|
+
return canonicalDirectory(lexical, "Worktree container");
|
|
1238
1239
|
}
|
|
1239
1240
|
async function canonicalDirectory(path, label) {
|
|
1240
1241
|
const value = requireText(path, label);
|
|
@@ -15,7 +15,7 @@ import { taskRoleRuntimeIdentity } from "../runtime/managedCaller.js";
|
|
|
15
15
|
import { managedTaskRoot } from "../storage/homeLayout.js";
|
|
16
16
|
import { StorageConflictError } from "../storage/taskStore.js";
|
|
17
17
|
import { validateDraftTaskForActivation } from "../task/draftPlan.js";
|
|
18
|
-
import { activateTask, bindTaskProjectCommits, bindTaskWorkspaceIdentity, synchronizeTaskProjectCommits
|
|
18
|
+
import { activateTask, bindTaskProjectCommits, bindTaskWorkspaceIdentity, synchronizeTaskProjectCommits } from "../task/task.js";
|
|
19
19
|
import { admitStoredTaskActivation, adoptTaskActivationResources, recordAdoptedTaskActivation, recordFailedTaskActivation } from "../task/taskActivationService.js";
|
|
20
20
|
import { taskLocalActor } from "../task/taskAuthority.js";
|
|
21
21
|
import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, recordWorkItemWorkspaceDisposition, workItemExecutionGroupById } from "../workItem/workItem.js";
|
|
@@ -140,11 +140,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
140
140
|
const actor = task.activationRequest?.operation.actorId === `task:${task.id}/role:leader`
|
|
141
141
|
? "leader" : caller;
|
|
142
142
|
if (task.status === "active") {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
|
|
147
|
-
}
|
|
143
|
+
const workspace = this.store.getTaskWorkspace(task.id);
|
|
144
|
+
if (!isTaskOwnedWorkspace(workspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
|
|
145
|
+
throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
|
|
148
146
|
}
|
|
149
147
|
return {
|
|
150
148
|
task,
|
|
@@ -210,9 +208,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
210
208
|
// Acquire before resource adoption: a timed-out/cancelled waiter must
|
|
211
209
|
// not leave an adopted environment to be repeated on the next attempt.
|
|
212
210
|
const adopted = await this.#adoptActivationEnvironment(current);
|
|
213
|
-
return adopted
|
|
214
|
-
? this.#activateWorkspaceFreeTask(current.id, adopted, actor)
|
|
215
|
-
: await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
|
|
211
|
+
return await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
|
|
216
212
|
}
|
|
217
213
|
finally {
|
|
218
214
|
release();
|
|
@@ -222,10 +218,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
222
218
|
* Prepares and adopts the resource configuration an explicit activation
|
|
223
219
|
* request named, before any status change.
|
|
224
220
|
*
|
|
225
|
-
* A request whose plan is empty and whose Task binds no Project owns nothing
|
|
226
|
-
* physical, and is reported as workspace-free
|
|
227
|
-
* so activation creates no directory at all.
|
|
228
|
-
*
|
|
229
221
|
* The exact request that was adopted travels with the result, so the later
|
|
230
222
|
* status transaction can prove it is completing that request rather than
|
|
231
223
|
* whichever one occupies the slot by then.
|
|
@@ -241,8 +233,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
241
233
|
// rechecks. A failure here leaves the Task a continuable Draft.
|
|
242
234
|
const adopted = adoptTaskActivationResources(this.store, task.id, this.now);
|
|
243
235
|
return {
|
|
244
|
-
workspaceFree: admission.plan.kind === "empty"
|
|
245
|
-
&& task.projectBindings.length === 0,
|
|
246
236
|
// The request whose resources were actually adopted, re-checked at the
|
|
247
237
|
// status transaction: a cancel or replacement landing during workspace
|
|
248
238
|
// preparation must fail rather than silently swap requests.
|
|
@@ -254,47 +244,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
254
244
|
: { preparationId: adopted.preparation.id })
|
|
255
245
|
};
|
|
256
246
|
}
|
|
257
|
-
/**
|
|
258
|
-
* Moves a Task that owns nothing physical from Draft to active.
|
|
259
|
-
*
|
|
260
|
-
* An empty environment plan with no bound Project is a legal Task shape, so
|
|
261
|
-
* no workspace view, worktree or cwd is created to satisfy the framework's
|
|
262
|
-
* own model. The status change and the adopted request commit together.
|
|
263
|
-
*/
|
|
264
|
-
#activateWorkspaceFreeTask(taskId, environment, actor) {
|
|
265
|
-
const task = this.store.transaction((tx) => {
|
|
266
|
-
const latest = requireTask(tx, taskId);
|
|
267
|
-
if (latest.status !== "draft") {
|
|
268
|
-
throw new Error(`Task changed while activating it: ${taskId}/${latest.status}.`);
|
|
269
|
-
}
|
|
270
|
-
if (latest.projectBindings.length !== 0
|
|
271
|
-
|| latest.cwd !== undefined
|
|
272
|
-
|| latest.workspaceIdentity !== undefined
|
|
273
|
-
|| tx.getTaskWorkspace(taskId) !== null) {
|
|
274
|
-
throw new Error(`Task acquired workspace state while activating it: ${taskId}.`);
|
|
275
|
-
}
|
|
276
|
-
validateDraftTaskForActivation(tx, latest);
|
|
277
|
-
const timestamp = this.now();
|
|
278
|
-
// Bind before the handover below: the desired environment must be the
|
|
279
|
-
// adopted one before the obligation that ends the old physical Host
|
|
280
|
-
// exists.
|
|
281
|
-
if (environment.preparationId !== undefined) {
|
|
282
|
-
this.#bindAdoptedActivationEnvironment(tx, taskId, environment.preparationId);
|
|
283
|
-
}
|
|
284
|
-
// The physical launch is unchanged, but "unchanged" is not "compatible":
|
|
285
|
-
// the Draft's planning Session runs in the Role's inherited workspace,
|
|
286
|
-
// which is exactly the shared cwd an active Task may not execute in. Hand
|
|
287
|
-
// the conversation over instead of asserting continuity, so the next
|
|
288
|
-
// launch is re-planned under the execution rules and must name a real
|
|
289
|
-
// environment. Its plan and grants are re-proven there, not assumed here.
|
|
290
|
-
handOverPlanningSession(tx, taskId, timestamp);
|
|
291
|
-
const activated = this.#recordAdoptedActivation(tx, activateTask(latest, timestamp), environment, timestamp);
|
|
292
|
-
tx.saveTask(activated);
|
|
293
|
-
recordTaskActivation(tx, latest, activated, timestamp, actor);
|
|
294
|
-
return activated;
|
|
295
|
-
});
|
|
296
|
-
return { task, taskId, status: "ready", changed: true };
|
|
297
|
-
}
|
|
298
247
|
/**
|
|
299
248
|
* Marks the Task's activation request adopted, inside the caller's activation
|
|
300
249
|
* transaction. Returns the Task unchanged when no explicit request exists.
|
|
@@ -406,7 +355,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
406
355
|
}
|
|
407
356
|
async #prepareTaskWorkspaceLocked(taskId, activate,
|
|
408
357
|
/** Environment and request identity the activation adopted, when one exists. */
|
|
409
|
-
activationEnvironment = {
|
|
358
|
+
activationEnvironment = {}, actor = "user") {
|
|
410
359
|
const task = requireTask(this.store, taskId);
|
|
411
360
|
if (task.executionGate.state !== "enabled") {
|
|
412
361
|
throw new Error(`Task execution is not enabled: ${task.id}.`);
|
|
@@ -695,6 +644,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
695
644
|
throw new StorageConflictError(`Task workspace changed while preparing its workspace: ${task.id}.`);
|
|
696
645
|
}
|
|
697
646
|
const timestamp = this.now();
|
|
647
|
+
const mainWorkspaceChanged = current !== null
|
|
648
|
+
&& (current.root !== workspace.root
|
|
649
|
+
|| !isDeepStrictEqual(current.entries.map(({ projectId, directory, access, path, branch }) => ({ projectId, directory, access, path, branch })), workspace.entries.map(({ projectId, directory, access, path, branch }) => ({ projectId, directory, access, path, branch }))));
|
|
698
650
|
let persistedTask = latest;
|
|
699
651
|
if (activate) {
|
|
700
652
|
// Activation moves the Leader out of the Draft planning cwd, so its
|
|
@@ -784,6 +736,12 @@ export class FileTaskWorkspacePreparer {
|
|
|
784
736
|
}
|
|
785
737
|
tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
|
|
786
738
|
}
|
|
739
|
+
else if (target === root && mainWorkspaceChanged) {
|
|
740
|
+
// A newly bound Project changes the native directory grants even
|
|
741
|
+
// though the Task cwd stays at the same root. Do not resume a
|
|
742
|
+
// Session frozen before that Project was present.
|
|
743
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
744
|
+
}
|
|
787
745
|
}
|
|
788
746
|
if (activate)
|
|
789
747
|
recordTaskActivation(tx, latest, persistedTask, timestamp, actor);
|