@zq-silk/yui 1.0.0 → 1.1.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 +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 +18 -28
- 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 +230 -12
- 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 +15 -25
- 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 +13 -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/sessionTitle.js +15 -0
- package/dist/runtime/structuredProviderHost.js +94 -29
- 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/managed-turn-and-session-runtime.md +7 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +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 +9 -7
- package/i18n/README.zh-CN.md +19 -6
- package/package.json +5 -6
- package/skills/yui-leader/SKILL.md +8 -0
- package/skills/yui-leader/references/execution.md +4 -1
- package/skills/yui-operator/SKILL.md +35 -15
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { chmodSync, closeSync, openSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { currentProcessStartIdentity, readProcessStartIdentity } from "../core/fileLockOwner.js";
|
|
4
5
|
/** Durable identity written by an isolated integration-test runtime. */
|
|
5
6
|
export const CONTROLLER_DOMAIN_PATH = "runtime/domain.json";
|
|
6
7
|
const EPHEMERAL_DOMAIN_LOCK_PATH = "runtime/domain.identity.lock";
|
|
@@ -21,9 +22,11 @@ export const EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES = [
|
|
|
21
22
|
export function createEphemeralDomainIdentity(options) {
|
|
22
23
|
const hostPid = options.hostPid ?? process.pid;
|
|
23
24
|
const hostProcessStartIdentity = options.hostProcessStartIdentity
|
|
24
|
-
??
|
|
25
|
+
?? (hostPid === process.pid
|
|
26
|
+
? currentProcessStartIdentity()
|
|
27
|
+
: readProcessStartIdentity(hostPid));
|
|
25
28
|
if (hostProcessStartIdentity === undefined) {
|
|
26
|
-
throw new Error(`Cannot read
|
|
29
|
+
throw new Error(`Cannot read process start identity for host PID ${hostPid}.`);
|
|
27
30
|
}
|
|
28
31
|
const token = options.token ?? randomBytes(32).toString("hex");
|
|
29
32
|
const identity = {
|
|
@@ -407,10 +410,7 @@ function acquireEphemeralDomainIdentityLock(home) {
|
|
|
407
410
|
const directory = dirname(lockPath);
|
|
408
411
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
409
412
|
chmodSync(directory, 0o700);
|
|
410
|
-
const processStartIdentity =
|
|
411
|
-
if (processStartIdentity === undefined) {
|
|
412
|
-
throw new Error(`Cannot read Linux process start identity for lock owner PID ${process.pid}.`);
|
|
413
|
-
}
|
|
413
|
+
const processStartIdentity = currentProcessStartIdentity();
|
|
414
414
|
const owner = {
|
|
415
415
|
pid: process.pid,
|
|
416
416
|
processStartIdentity,
|
|
@@ -469,7 +469,7 @@ function reclaimStaleEphemeralDomainLock(lockPath) {
|
|
|
469
469
|
catch {
|
|
470
470
|
return false;
|
|
471
471
|
}
|
|
472
|
-
const currentStartIdentity =
|
|
472
|
+
const currentStartIdentity = readProcessStartIdentity(owner.pid);
|
|
473
473
|
if (currentStartIdentity !== undefined
|
|
474
474
|
&& currentStartIdentity === owner.processStartIdentity)
|
|
475
475
|
return false;
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { buildRuntimeIdentityReceipt } from "../core/controllerServer.js";
|
|
17
|
+
import { currentProcessStartIdentity } from "../core/fileLockOwner.js";
|
|
17
18
|
import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
|
|
18
19
|
import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
|
|
19
20
|
import { detectRunningRelease, isOwnerLive, readActiveReleasePointer, readHandoverFence, removeCandidateDiscovery, removeHandoverFence, writeCandidateDiscovery, writeHandoverFence, writeHandoverReceipt, writeRuntimeIdentity } from "../release/runtimeRelease.js";
|
|
20
21
|
import { startFileTaskControllerRuntime } from "./runtime.js";
|
|
21
|
-
import { readLinuxProcessStartIdentity } from "./domainIdentity.js";
|
|
22
22
|
import { RELEASE_HANDOVER_OLD_OWNER_GRACE_MS } from "../runtime/runtimeDeadlines.js";
|
|
23
23
|
export const CONTROLLER_CANDIDATE_ENV = "YUI_CONTROLLER_CANDIDATE";
|
|
24
24
|
export const CONTROLLER_HANDOVER_ID_ENV = "YUI_CONTROLLER_HANDOVER_ID";
|
|
@@ -53,10 +53,7 @@ export async function runHandoverCandidate(home, handoverId, options = {}) {
|
|
|
53
53
|
const dualOwnerGraceMs = options.dualOwnerGraceMs ?? DEFAULT_DUAL_OWNER_GRACE_MS;
|
|
54
54
|
const now = options.now ?? (() => new Date());
|
|
55
55
|
const release = detectRunningRelease(fileURLToPath(import.meta.url));
|
|
56
|
-
const startIdentity =
|
|
57
|
-
if (startIdentity === undefined) {
|
|
58
|
-
throw new Error(`Cannot read process start identity for handover candidate PID ${process.pid}.`);
|
|
59
|
-
}
|
|
56
|
+
const startIdentity = currentProcessStartIdentity();
|
|
60
57
|
const fence = readHandoverFence(home);
|
|
61
58
|
if (fence === null || fence.handoverId !== handoverId) {
|
|
62
59
|
throw new Error(`Handover fence for ${handoverId} is missing or does not match; aborting candidate.`);
|
|
@@ -19,7 +19,7 @@ import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markD
|
|
|
19
19
|
import { recordOperationEvidence } from "../kernel/operationFacts.js";
|
|
20
20
|
import { wakeReason } from "../scheduler/wakeReason.js";
|
|
21
21
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
22
|
-
import {
|
|
22
|
+
import { readProcessStartIdentity } from "../core/fileLockOwner.js";
|
|
23
23
|
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
|
|
24
24
|
const HEARTBEAT_STALE_MS = 2 * 60_000;
|
|
25
25
|
const SIGKILL_GRACE_MS = 30_000;
|
|
@@ -401,7 +401,7 @@ export function createLinuxProcessPort() {
|
|
|
401
401
|
spawnJobRunner(specPath) {
|
|
402
402
|
const child = spawn(process.execPath, [fileURLToPath(new URL("../job/jobRunner.js", import.meta.url)), specPath], { detached: true, stdio: "ignore" });
|
|
403
403
|
child.unref();
|
|
404
|
-
const startIdentity =
|
|
404
|
+
const startIdentity = readProcessStartIdentity(child.pid);
|
|
405
405
|
return {
|
|
406
406
|
pid: child.pid,
|
|
407
407
|
startIdentity,
|
|
@@ -416,7 +416,7 @@ export function createLinuxProcessPort() {
|
|
|
416
416
|
};
|
|
417
417
|
},
|
|
418
418
|
processStartIdentity(pid) {
|
|
419
|
-
return
|
|
419
|
+
return readProcessStartIdentity(pid);
|
|
420
420
|
},
|
|
421
421
|
isProcessAlive(pid) {
|
|
422
422
|
try {
|
|
@@ -428,7 +428,7 @@ export function createLinuxProcessPort() {
|
|
|
428
428
|
}
|
|
429
429
|
},
|
|
430
430
|
signalIfOwned(pid, startIdentity, signal) {
|
|
431
|
-
if (
|
|
431
|
+
if (readProcessStartIdentity(pid) !== startIdentity)
|
|
432
432
|
return;
|
|
433
433
|
try {
|
|
434
434
|
process.kill(pid, signal);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
2
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { readProcessStartIdentity } from "../core/fileLockOwner.js";
|
|
3
5
|
import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
|
|
4
6
|
import { TmuxManager } from "../tmux/tmuxManager.js";
|
|
5
7
|
import { tmuxSocketDirectory, tmuxSocketEnvironment } from "../tmux/tmuxSocketEndpoint.js";
|
|
@@ -17,7 +19,7 @@ export async function cleanControllerResource(resource, options = {}) {
|
|
|
17
19
|
const ports = options.ports ?? linuxCleanupPorts(environment, options.tmuxBin ?? "tmux");
|
|
18
20
|
const cleanup = async () => {
|
|
19
21
|
if (resource.artifact !== undefined) {
|
|
20
|
-
cleanArtifact(resource, ports, environment);
|
|
22
|
+
await cleanArtifact(resource, ports, environment);
|
|
21
23
|
return;
|
|
22
24
|
}
|
|
23
25
|
if (resource.kind === "agent-session") {
|
|
@@ -88,7 +90,7 @@ function assertEphemeralDomainFence(resource) {
|
|
|
88
90
|
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
|
-
function cleanArtifact(resource, ports, environment) {
|
|
93
|
+
async function cleanArtifact(resource, ports, environment) {
|
|
92
94
|
const artifact = resource.artifact;
|
|
93
95
|
if (artifact === undefined)
|
|
94
96
|
throw new Error(`Artifact is unavailable: ${resource.id}.`);
|
|
@@ -116,9 +118,15 @@ function cleanArtifact(resource, ports, environment) {
|
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
120
|
if (artifact.artifactKind.endsWith("-socket")
|
|
119
|
-
&& ports.socketActive(artifact.path)) {
|
|
121
|
+
&& await ports.socketActive(artifact.path)) {
|
|
120
122
|
throw new Error(`Resource socket is active: ${artifact.path}.`);
|
|
121
123
|
}
|
|
124
|
+
// The listener probe is asynchronous on macOS. A concurrent owner may
|
|
125
|
+
// replace the path while it runs; the pre-probe fingerprint no longer
|
|
126
|
+
// authorizes unlinking that new socket.
|
|
127
|
+
if (ports.artifactFingerprint(artifact.path) !== artifact.fingerprint) {
|
|
128
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
129
|
+
}
|
|
122
130
|
ports.removeArtifact(artifact.path);
|
|
123
131
|
}
|
|
124
132
|
function assertProcessIdentities(resource, ports) {
|
|
@@ -153,7 +161,7 @@ async function waitForProcesses(resource, ports, graceMs, pollMs) {
|
|
|
153
161
|
}
|
|
154
162
|
function linuxCleanupPorts(environment, tmuxBin) {
|
|
155
163
|
return {
|
|
156
|
-
processStartIdentity:
|
|
164
|
+
processStartIdentity: readOwnedProcessStartIdentity,
|
|
157
165
|
signal: (pid, signal) => process.kill(pid, signal),
|
|
158
166
|
sleep: (milliseconds) => new Promise((resolveSleep) => {
|
|
159
167
|
setTimeout(resolveSleep, milliseconds);
|
|
@@ -166,7 +174,8 @@ function linuxCleanupPorts(environment, tmuxBin) {
|
|
|
166
174
|
return undefined;
|
|
167
175
|
}
|
|
168
176
|
},
|
|
169
|
-
socketActive:
|
|
177
|
+
socketActive: process.platform === "darwin"
|
|
178
|
+
? darwinUnixSocketIsActive : unixSocketIsActive,
|
|
170
179
|
removeArtifact: (path) => unlinkSync(path),
|
|
171
180
|
inspectTmuxServerPanes: async (resource) => {
|
|
172
181
|
const manager = tmuxManagerForResource(resource, environment, tmuxBin);
|
|
@@ -218,20 +227,8 @@ function tmuxManagerForResource(resource, environment, tmuxBin) {
|
|
|
218
227
|
})
|
|
219
228
|
}, { yuiHome: home });
|
|
220
229
|
}
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
224
|
-
const closing = stat.lastIndexOf(")");
|
|
225
|
-
if (closing < 0)
|
|
226
|
-
return undefined;
|
|
227
|
-
const identity = stat.slice(closing + 1).trim().split(/\s+/u)[19];
|
|
228
|
-
return identity !== undefined && /^[0-9]{1,32}$/u.test(identity)
|
|
229
|
-
? identity
|
|
230
|
-
: undefined;
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
return undefined;
|
|
234
|
-
}
|
|
230
|
+
function readOwnedProcessStartIdentity(pid) {
|
|
231
|
+
return readProcessStartIdentity(pid);
|
|
235
232
|
}
|
|
236
233
|
function unixSocketIsActive(expectedPath) {
|
|
237
234
|
try {
|
|
@@ -243,6 +240,25 @@ function unixSocketIsActive(expectedPath) {
|
|
|
243
240
|
return true;
|
|
244
241
|
}
|
|
245
242
|
}
|
|
243
|
+
function darwinUnixSocketIsActive(path) {
|
|
244
|
+
return new Promise((resolveActive) => {
|
|
245
|
+
const socket = createConnection(path);
|
|
246
|
+
let settled = false;
|
|
247
|
+
const finish = (active) => {
|
|
248
|
+
if (settled)
|
|
249
|
+
return;
|
|
250
|
+
settled = true;
|
|
251
|
+
socket.destroy();
|
|
252
|
+
resolveActive(active);
|
|
253
|
+
};
|
|
254
|
+
socket.once("connect", () => finish(true));
|
|
255
|
+
socket.once("error", (error) => {
|
|
256
|
+
finish(error.code !== "ENOENT" && error.code !== "ECONNREFUSED");
|
|
257
|
+
});
|
|
258
|
+
socket.once("close", () => finish(true));
|
|
259
|
+
socket.setTimeout(1_000, () => finish(true));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
246
262
|
function assertOwnedArtifactPath(resource, environment) {
|
|
247
263
|
const artifact = resource.artifact;
|
|
248
264
|
if (artifact === undefined)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
3
4
|
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { processOwnerIsLive, readProcessStartIdentity } from "../core/fileLockOwner.js";
|
|
4
6
|
import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
|
|
5
7
|
import { inspectStorageSchema } from "../storage/storageSchema.js";
|
|
6
8
|
import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
|
|
@@ -12,16 +14,31 @@ import { CONTROLLER_DISCOVERY_PATH } from "../core/protocol.js";
|
|
|
12
14
|
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
13
15
|
import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
|
|
14
16
|
import { buildControllerResourceInventory, runtimeRoleProtectsDomain } from "./resourceInventory.js";
|
|
15
|
-
import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity
|
|
17
|
+
import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity } from "./domainIdentity.js";
|
|
16
18
|
const LINUX_PROCESS_SCAN_BATCH_SIZE = 64;
|
|
17
19
|
export const INVENTORY_EVENT_LOOP_RUN_BUDGET_MS = 25;
|
|
18
20
|
export async function scanControllerResourceInventory(options) {
|
|
21
|
+
if (options.scope === "all" && process.platform !== "linux") {
|
|
22
|
+
throw new Error("Controller inventory --all requires cross-Home process discovery, which is unavailable on this platform. Use the current-Home scope.");
|
|
23
|
+
}
|
|
19
24
|
const currentHome = resolve(options.currentHome);
|
|
20
25
|
const environment = options.environment ?? process.env;
|
|
21
26
|
const observedAt = (options.now ?? (() => new Date()))();
|
|
22
27
|
const warnings = [];
|
|
23
|
-
const
|
|
24
|
-
|
|
28
|
+
const tmuxDirectory = tmuxSocketDirectory(environment);
|
|
29
|
+
// /proc-backed process and socket enumeration is Linux-only. On macOS the
|
|
30
|
+
// current Controller is synthesized from its exact discovery identity.
|
|
31
|
+
let activeSockets;
|
|
32
|
+
let processes;
|
|
33
|
+
if (process.platform === "linux") {
|
|
34
|
+
activeSockets = readActiveUnixSocketPaths(warnings);
|
|
35
|
+
processes = normalizePhysicalHomeAliases((await listLinuxProcesses(warnings)).filter(({ pid }) => pid !== process.pid), currentHome);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const observed = await observeCurrentController(currentHome, tmuxDirectory);
|
|
39
|
+
activeSockets = observed.activeSockets;
|
|
40
|
+
processes = observed.process;
|
|
41
|
+
}
|
|
25
42
|
const homes = new Set([currentHome]);
|
|
26
43
|
if (options.scope === "all") {
|
|
27
44
|
for (const process of processes) {
|
|
@@ -29,7 +46,6 @@ export async function scanControllerResourceInventory(options) {
|
|
|
29
46
|
homes.add(process.yuiHome);
|
|
30
47
|
}
|
|
31
48
|
}
|
|
32
|
-
const tmuxDirectory = tmuxSocketDirectory(environment);
|
|
33
49
|
const listTmuxSocketArtifacts = options.listTmuxSocketArtifacts
|
|
34
50
|
?? listSharedTmuxSocketArtifacts;
|
|
35
51
|
// scope=current owns exactly one YUI_HOME, so only its exact tmux server
|
|
@@ -86,7 +102,7 @@ export async function scanControllerResourceInventory(options) {
|
|
|
86
102
|
// otherwise its own expired-domain pass would delete controller.json
|
|
87
103
|
// and strand the still-running server without a callable endpoint.
|
|
88
104
|
|| (discovery.pid === process.pid
|
|
89
|
-
&&
|
|
105
|
+
&& readProcessStartIdentity(process.pid) === discovery.processStartIdentity));
|
|
90
106
|
const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
|
|
91
107
|
const expectedControllerSocketPath = state.homeId === undefined
|
|
92
108
|
? undefined
|
|
@@ -179,6 +195,78 @@ export function classifyRuntimeProcess(args, command) {
|
|
|
179
195
|
return "agent";
|
|
180
196
|
return "other";
|
|
181
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Non-Linux inventory observations. There is no /proc to enumerate, so the
|
|
200
|
+
* current Home's Controller is reconstructed from its discovery record when
|
|
201
|
+
* its process-generation identity is still live, and listening Unix sockets are proven with
|
|
202
|
+
* a direct connection (the equivalent of reading /proc/net/unix). Only the
|
|
203
|
+
* current Home is observable without /proc; scope=all is rejected at entry.
|
|
204
|
+
*/
|
|
205
|
+
async function observeCurrentController(currentHome, tmuxDirectory) {
|
|
206
|
+
const activeSockets = new Set();
|
|
207
|
+
for (const path of listProbeableSocketPaths(tmuxDirectory)) {
|
|
208
|
+
if (await unixSocketListens(path))
|
|
209
|
+
activeSockets.add(path);
|
|
210
|
+
}
|
|
211
|
+
let discovery;
|
|
212
|
+
try {
|
|
213
|
+
discovery = await readControllerDiscovery(currentHome);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return { activeSockets, process: [] };
|
|
217
|
+
}
|
|
218
|
+
if (await unixSocketListens(discovery.socketPath)) {
|
|
219
|
+
activeSockets.add(discovery.socketPath);
|
|
220
|
+
}
|
|
221
|
+
if (!processOwnerIsLive(discovery.pid, discovery.processStartIdentity)) {
|
|
222
|
+
return { activeSockets, process: [] };
|
|
223
|
+
}
|
|
224
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
225
|
+
const processFact = {
|
|
226
|
+
pid: discovery.pid,
|
|
227
|
+
ppid: 1,
|
|
228
|
+
uid,
|
|
229
|
+
startIdentity: discovery.processStartIdentity,
|
|
230
|
+
yuiHome: currentHome,
|
|
231
|
+
...(discovery.homeFilesystemId === undefined
|
|
232
|
+
? {}
|
|
233
|
+
: { homeFilesystemId: discovery.homeFilesystemId }),
|
|
234
|
+
kind: "controller",
|
|
235
|
+
command: "yui-controller",
|
|
236
|
+
args: [],
|
|
237
|
+
rssBytes: 0,
|
|
238
|
+
cpuTimeMs: 0,
|
|
239
|
+
ageMs: 0
|
|
240
|
+
};
|
|
241
|
+
return { activeSockets, process: [processFact] };
|
|
242
|
+
}
|
|
243
|
+
function listProbeableSocketPaths(tmuxDirectory) {
|
|
244
|
+
try {
|
|
245
|
+
return readdirSync(tmuxDirectory, { encoding: "utf8" })
|
|
246
|
+
.filter((entry) => /^yui-[a-f0-9]{24}$/u.test(entry))
|
|
247
|
+
.map((entry) => join(tmuxDirectory, entry));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function unixSocketListens(path) {
|
|
254
|
+
return new Promise((resolveProbe) => {
|
|
255
|
+
const socket = createConnection(path);
|
|
256
|
+
let settled = false;
|
|
257
|
+
const finish = (listens) => {
|
|
258
|
+
if (settled)
|
|
259
|
+
return;
|
|
260
|
+
settled = true;
|
|
261
|
+
socket.destroy();
|
|
262
|
+
resolveProbe(listens);
|
|
263
|
+
};
|
|
264
|
+
socket.once("connect", () => finish(true));
|
|
265
|
+
socket.once("error", () => finish(false));
|
|
266
|
+
const timer = setTimeout(() => finish(false), 200);
|
|
267
|
+
timer.unref();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
182
270
|
async function listLinuxProcesses(warnings) {
|
|
183
271
|
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
184
272
|
const clockTicks = readClockTicks();
|
|
@@ -497,8 +585,8 @@ function inspectRuntimeDomain(home, processes, roles, storageStatus, now) {
|
|
|
497
585
|
graceMs: EPHEMERAL_DOMAIN_GRACE_MS
|
|
498
586
|
};
|
|
499
587
|
}
|
|
500
|
-
const hostStartIdentity =
|
|
501
|
-
const hostPathExists =
|
|
588
|
+
const hostStartIdentity = readProcessStartIdentity(identity.hostPid);
|
|
589
|
+
const hostPathExists = processExists(identity.hostPid);
|
|
502
590
|
const hostActive = hostStartIdentity === identity.hostProcessStartIdentity;
|
|
503
591
|
const storageSafe = storageStatus === "current";
|
|
504
592
|
if (hostActive) {
|
|
@@ -607,6 +695,15 @@ function inspectRuntimeDomain(home, processes, roles, storageStatus, now) {
|
|
|
607
695
|
graceMs: EPHEMERAL_DOMAIN_GRACE_MS
|
|
608
696
|
};
|
|
609
697
|
}
|
|
698
|
+
function processExists(pid) {
|
|
699
|
+
try {
|
|
700
|
+
process.kill(pid, 0);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
return error.code !== "ESRCH";
|
|
705
|
+
}
|
|
706
|
+
}
|
|
610
707
|
function loadHomeState(home, warnings, options) {
|
|
611
708
|
const schema = (options.inspectStorage ?? inspectStorageSchema)(home);
|
|
612
709
|
if (schema.status !== "current") {
|
|
@@ -27,7 +27,6 @@ import { managedRuntimeRoot } from "../storage/homeLayout.js";
|
|
|
27
27
|
import { SqliteTaskStore } from "../storage/sqliteStore.js";
|
|
28
28
|
import { AsyncTaskStoreClient, resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
|
|
29
29
|
import { SurfaceContributions } from "../surface/surfaceContributions.js";
|
|
30
|
-
import { taskOwnsManagedWorkspace } from "../task/task.js";
|
|
31
30
|
import { openSchedulerTelemetry } from "../telemetry/telemetryWiring.js";
|
|
32
31
|
import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
|
|
33
32
|
import { TmuxManager, yuiTmuxServerName } from "../tmux/tmuxManager.js";
|
|
@@ -817,13 +816,10 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
|
|
|
817
816
|
const planningDraft = request.scope === "task"
|
|
818
817
|
&& activeRun?.purpose === "planning"
|
|
819
818
|
&& task?.status === "draft";
|
|
820
|
-
//
|
|
821
|
-
// workspace, so there is nothing to prove ready. Every Task that does own
|
|
822
|
-
// one keeps the full ownership fence.
|
|
819
|
+
// Draft planning precedes workspace adoption; active Tasks need ownership.
|
|
823
820
|
if (request.scope === "task"
|
|
824
821
|
&& task !== null
|
|
825
|
-
&& !planningDraft
|
|
826
|
-
&& taskOwnsManagedWorkspace(task)) {
|
|
822
|
+
&& !planningDraft) {
|
|
827
823
|
const taskWorkspace = store.getTaskWorkspace(task.id);
|
|
828
824
|
if (!isTaskOwnedWorkspace(taskWorkspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
|
|
829
825
|
throw new Error(`Task workspace is not ready: ${task.id}.`);
|
|
@@ -849,16 +845,11 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
|
|
|
849
845
|
? activeRun?.workspace
|
|
850
846
|
?? currentDesiredManagedWorkspace(store, request.taskId, request.roleName)
|
|
851
847
|
: undefined;
|
|
852
|
-
//
|
|
853
|
-
// authoritative workspace is missing". Only the former may launch without
|
|
854
|
-
// one. Two cases qualify: an empty plan binding no Project, and a Draft
|
|
855
|
-
// planning conversation, which may bind a Project but has not activated, so
|
|
856
|
-
// no worktree exists or is owed. Without the planning term a Project-bound
|
|
857
|
-
// Draft fails closed on a workspace activation was never asked to create.
|
|
848
|
+
// Only Draft planning may launch without an adopted delivery workspace.
|
|
858
849
|
const workspaceFree = request.scope === "task"
|
|
859
850
|
&& managedWorkspace === undefined
|
|
860
851
|
&& task !== null
|
|
861
|
-
&&
|
|
852
|
+
&& planningDraft;
|
|
862
853
|
const sessions = request.scope === "task"
|
|
863
854
|
? store.getTaskRoleSessionSet(request.taskId, request.roleName)
|
|
864
855
|
: store.getGlobalRoleSessionSet(request.roleName);
|
|
@@ -48,10 +48,8 @@ export class RuntimeLaunchCoordinator {
|
|
|
48
48
|
let isolation;
|
|
49
49
|
if (request.owner.scope === "task" && this.options.runtimeIsolation !== undefined) {
|
|
50
50
|
const workspace = request.managedWorkspace;
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// here is what lets such a Leader launch at all; a *missing*
|
|
54
|
-
// authoritative workspace still fails closed below.
|
|
51
|
+
// Draft planning has no delivery workspace to isolate. Active Task
|
|
52
|
+
// launches require the exact authoritative ManagedWorkspace below.
|
|
55
53
|
if (request.workspaceFree === true) {
|
|
56
54
|
if (workspace !== undefined) {
|
|
57
55
|
throw new Error("A workspace-free Task launch cannot carry a ManagedWorkspace.");
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { processOwnerIsLive } from "../core/fileLockOwner.js";
|
|
3
4
|
import { createTaskEvent } from "../event/taskEvent.js";
|
|
4
5
|
import { createGlobalRoleMessage } from "../message/message.js";
|
|
5
|
-
import { createSessionOwnerIdentity, isLinuxProcessLive, listOwnedProcessTree, readLinuxProcessIdentity, reconcileSessionOwners, terminateSessionOwners } from "../runtime/index.js";
|
|
6
|
+
import { createSessionOwnerIdentity, isLinuxProcessLive, listOwnedProcessTree, readLinuxProcessIdentity, readProcessIdentity, reconcileSessionOwners, terminateSessionOwners } from "../runtime/index.js";
|
|
6
7
|
import { sessionOwnerProcessKey } from "../runtime/sessionOwnerIdentity.js";
|
|
7
8
|
import { AGENT_HOST_CONTROL_PROTOCOL, sendAgentHostCancelControl } from "../runtime/agentHost.js";
|
|
8
9
|
import { settleProviderTurn, cancelQuiescentProviderInput, clearProviderGoal } from "../runtime/providerRuntimeIdentity.js";
|
|
@@ -45,7 +46,7 @@ export class SessionOwnerReconciliation {
|
|
|
45
46
|
*/
|
|
46
47
|
recordHostOwner(input) {
|
|
47
48
|
const observed = input.panePid !== undefined
|
|
48
|
-
?
|
|
49
|
+
? readOwnerProcessIdentity(input.panePid)
|
|
49
50
|
: undefined;
|
|
50
51
|
const root = observed === undefined
|
|
51
52
|
? undefined
|
|
@@ -131,7 +132,10 @@ export class SessionOwnerReconciliation {
|
|
|
131
132
|
at: new Date() });
|
|
132
133
|
throw error;
|
|
133
134
|
}
|
|
134
|
-
const result = await terminateSessionOwners(owner, records, this.#terminationPorts(),
|
|
135
|
+
const result = await terminateSessionOwners(owner, records, this.#terminationPorts(), {
|
|
136
|
+
...options,
|
|
137
|
+
requireProcessRecord: process.platform === "darwin"
|
|
138
|
+
});
|
|
135
139
|
if (result.outcome === "stop-confirmed") {
|
|
136
140
|
// A signalled Host exits into a retained tmux pane. Explicit release
|
|
137
141
|
// removes that window too, after physical exit proof, not on observation.
|
|
@@ -158,15 +162,8 @@ export class SessionOwnerReconciliation {
|
|
|
158
162
|
}
|
|
159
163
|
return this.#tmux.probeRoleStatus(hostId, target.roleName) === "exited";
|
|
160
164
|
},
|
|
161
|
-
processIdentity:
|
|
162
|
-
procEntryExists:
|
|
163
|
-
try {
|
|
164
|
-
return existsSync(`/proc/${pid}`);
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
},
|
|
165
|
+
processIdentity: readOwnerProcessIdentity,
|
|
166
|
+
procEntryExists: processEntryExists,
|
|
170
167
|
signalProcess: (pid, signal) => process.kill(pid, signal),
|
|
171
168
|
sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
172
169
|
emit: (event) => this.#recordTerminationEvent(event),
|
|
@@ -395,8 +392,10 @@ export function durableSessionFacts(store) {
|
|
|
395
392
|
/** /proc observation for one owner record; undefined is a verification gap. */
|
|
396
393
|
export function observeSessionOwnerPhysical(record) {
|
|
397
394
|
const { pid, startIdentity } = record.providerRoot;
|
|
398
|
-
const current =
|
|
395
|
+
const current = readOwnerProcessIdentity(pid);
|
|
399
396
|
if (current === undefined) {
|
|
397
|
+
if (processEntryExists(pid))
|
|
398
|
+
return undefined;
|
|
400
399
|
return {
|
|
401
400
|
alive: false,
|
|
402
401
|
identityConflict: false,
|
|
@@ -432,7 +431,8 @@ export function observeSessionOwnerPhysical(record) {
|
|
|
432
431
|
childCount: 0
|
|
433
432
|
};
|
|
434
433
|
}
|
|
435
|
-
const tree =
|
|
434
|
+
const tree = process.platform === "linux"
|
|
435
|
+
? listOwnedProcessTree(pid, current.processGroupId) : [];
|
|
436
436
|
return {
|
|
437
437
|
alive: true,
|
|
438
438
|
identityConflict: false,
|
|
@@ -440,9 +440,30 @@ export function observeSessionOwnerPhysical(record) {
|
|
|
440
440
|
startIdentity,
|
|
441
441
|
rssBytes: current.rssBytes,
|
|
442
442
|
ageMs: 0,
|
|
443
|
-
childCount: Math.max(0, tree.length - 1)
|
|
443
|
+
childCount: process.platform === "linux" ? Math.max(0, tree.length - 1) : 0
|
|
444
444
|
};
|
|
445
445
|
}
|
|
446
|
+
function readOwnerProcessIdentity(pid) {
|
|
447
|
+
if (process.platform === "linux")
|
|
448
|
+
return readLinuxProcessIdentity(pid);
|
|
449
|
+
try {
|
|
450
|
+
return readProcessIdentity(pid);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function processEntryExists(pid) {
|
|
457
|
+
if (process.platform === "linux")
|
|
458
|
+
return existsSync(`/proc/${pid}`);
|
|
459
|
+
try {
|
|
460
|
+
process.kill(pid, 0);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
return error.code !== "ESRCH";
|
|
465
|
+
}
|
|
466
|
+
}
|
|
446
467
|
/** Reads the latest termination outcome recorded for one Role. */
|
|
447
468
|
export function lastSessionTerminationOutcome(store, taskId, roleName) {
|
|
448
469
|
if (taskId === undefined)
|
|
@@ -459,7 +480,10 @@ export function lastSessionTerminationOutcome(store, taskId, roleName) {
|
|
|
459
480
|
}
|
|
460
481
|
/** True when one owner record's Provider root is still the exact live process. */
|
|
461
482
|
export function ownerRootIsLive(record) {
|
|
462
|
-
|
|
483
|
+
const { pid, startIdentity } = record.providerRoot;
|
|
484
|
+
return process.platform === "linux"
|
|
485
|
+
? isLinuxProcessLive(pid, startIdentity)
|
|
486
|
+
: processOwnerIsLive(pid, startIdentity);
|
|
463
487
|
}
|
|
464
488
|
/** Exact tmux target for one owner, for reports and diagnostics. */
|
|
465
489
|
export function ownerTmuxTarget(home, owner) {
|
|
@@ -8,8 +8,8 @@ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
|
|
|
8
8
|
import { CONTROLLER_DISCOVERY_PATH, ControllerProtocolError, encodeControllerRequest, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
|
|
9
9
|
export class ControllerClientError extends Error {
|
|
10
10
|
code;
|
|
11
|
-
constructor(code, message) {
|
|
12
|
-
super(message);
|
|
11
|
+
constructor(code, message, cause) {
|
|
12
|
+
super(message, { cause });
|
|
13
13
|
this.code = code;
|
|
14
14
|
this.name = "ControllerClientError";
|
|
15
15
|
}
|
|
@@ -50,9 +50,12 @@ export async function readControllerDiscovery(home) {
|
|
|
50
50
|
if (error instanceof ControllerClientError)
|
|
51
51
|
throw error;
|
|
52
52
|
if (isNodeError(error) && error.code === "ENOENT") {
|
|
53
|
-
throw new ControllerClientError("CONTROLLER_NOT_RUNNING", "Controller is not running.");
|
|
53
|
+
throw new ControllerClientError("CONTROLLER_NOT_RUNNING", withOriginalError("Controller is not running.", error), error);
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
if (isNodeError(error) && (error.code === "EPERM" || error.code === "EACCES")) {
|
|
56
|
+
throw controllerAccessDenied(error, "discovery record");
|
|
57
|
+
}
|
|
58
|
+
throw invalidDiscovery(error);
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
function discoverySocketPath(value) {
|
|
@@ -90,9 +93,9 @@ export async function callController(home, method, params = {}, options = {}) {
|
|
|
90
93
|
}
|
|
91
94
|
catch (error) {
|
|
92
95
|
if (error instanceof ControllerProtocolError) {
|
|
93
|
-
throw new ControllerClientError(error.code, error.message);
|
|
96
|
+
throw new ControllerClientError(error.code, error.message, error);
|
|
94
97
|
}
|
|
95
|
-
throw new ControllerClientError("INVALID_REQUEST", "Invalid controller request.");
|
|
98
|
+
throw new ControllerClientError("INVALID_REQUEST", withOriginalError("Invalid controller request.", error), error);
|
|
96
99
|
}
|
|
97
100
|
return exchange(discovery.socketPath, requestLine, id, timeoutMs);
|
|
98
101
|
}
|
|
@@ -201,26 +204,51 @@ function exchange(socketPath, requestLine, expectedId, timeoutMs) {
|
|
|
201
204
|
else
|
|
202
205
|
fail(new ControllerClientError(response.error.code, response.error.message));
|
|
203
206
|
}
|
|
204
|
-
catch {
|
|
205
|
-
fail(invalidResponse());
|
|
207
|
+
catch (error) {
|
|
208
|
+
fail(invalidResponse(error));
|
|
206
209
|
}
|
|
207
210
|
});
|
|
208
211
|
socket.on("end", () => {
|
|
209
212
|
if (!settled)
|
|
210
213
|
fail(invalidResponse());
|
|
211
214
|
});
|
|
212
|
-
socket.on("error", () => {
|
|
213
|
-
fail(deliveryStarted
|
|
214
|
-
? new ControllerClientError("CONTROLLER_DELIVERY_UNKNOWN", "Controller request delivery is unknown.")
|
|
215
|
-
: new ControllerClientError("CONTROLLER_UNAVAILABLE", "Controller is unavailable."));
|
|
215
|
+
socket.on("error", (error) => {
|
|
216
|
+
fail(classifyControllerSocketError(error, deliveryStarted));
|
|
216
217
|
});
|
|
217
218
|
});
|
|
218
219
|
}
|
|
219
|
-
function
|
|
220
|
-
|
|
220
|
+
export function classifyControllerSocketError(error, deliveryStarted) {
|
|
221
|
+
if (deliveryStarted) {
|
|
222
|
+
return new ControllerClientError("CONTROLLER_DELIVERY_UNKNOWN", withOriginalError("Controller request delivery is unknown.", error), error);
|
|
223
|
+
}
|
|
224
|
+
if (isNodeError(error) && (error.code === "EPERM" || error.code === "EACCES")) {
|
|
225
|
+
return controllerAccessDenied(error, "socket");
|
|
226
|
+
}
|
|
227
|
+
return new ControllerClientError("CONTROLLER_UNAVAILABLE", withOriginalError("Controller is unavailable.", error), error);
|
|
228
|
+
}
|
|
229
|
+
function controllerAccessDenied(error, target) {
|
|
230
|
+
return new ControllerClientError("CONTROLLER_ACCESS_DENIED", `Controller ${target} access was denied (${error.code}) before any request was sent. `
|
|
231
|
+
+ "In Codex, request sandbox escalation to rerun the same Yui CLI command outside the sandbox "
|
|
232
|
+
+ `if authorized. If escalation is denied, report that to the user.${originalErrorDetail(error)}`, error);
|
|
233
|
+
}
|
|
234
|
+
function invalidDiscovery(error) {
|
|
235
|
+
return new ControllerClientError("CONTROLLER_DISCOVERY_INVALID", error === undefined
|
|
236
|
+
? "Controller discovery is invalid."
|
|
237
|
+
: withOriginalError("Controller discovery is invalid.", error), error);
|
|
238
|
+
}
|
|
239
|
+
function invalidResponse(error) {
|
|
240
|
+
return new ControllerClientError("INVALID_RESPONSE", error === undefined
|
|
241
|
+
? "Controller response is invalid."
|
|
242
|
+
: withOriginalError("Controller response is invalid.", error), error);
|
|
243
|
+
}
|
|
244
|
+
function withOriginalError(message, error) {
|
|
245
|
+
return `${message}${originalErrorDetail(error)}`;
|
|
221
246
|
}
|
|
222
|
-
function
|
|
223
|
-
|
|
247
|
+
function originalErrorDetail(error) {
|
|
248
|
+
const code = isNodeError(error) && typeof error.code === "string"
|
|
249
|
+
? ` (${error.code})`
|
|
250
|
+
: "";
|
|
251
|
+
return ` Original error${code}: ${error instanceof Error ? error.message : String(error)}`;
|
|
224
252
|
}
|
|
225
253
|
function isNodeError(error) {
|
|
226
254
|
return error instanceof Error && "code" in error;
|