@zq-silk/yui 0.6.7 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/commands/taskCommands.js +1 -1
- package/dist/controller/clientRuntime.js +26 -3
- package/dist/controller/fileSchedulerStoreAdapter.js +27 -4
- package/dist/controller/resourceCleanupLinux.js +2 -1
- package/dist/controller/resourceInventory.js +4 -2
- package/dist/controller/resourceInventoryLinux.js +62 -5
- package/dist/controller/runtime.js +2 -1
- package/dist/core/controllerClient.js +141 -7
- package/dist/core/controllerEndpoint.js +18 -30
- package/dist/core/controllerProcessIdentity.js +56 -0
- package/dist/core/controllerServer.js +90 -8
- package/dist/core/homeFilesystemIdentity.js +24 -0
- package/dist/core/protocol.js +68 -5
- package/dist/integration/gitIntegrationService.js +3 -3
- package/dist/repository/homeIdentity.js +7 -3
- package/dist/resources/liveReferences.js +35 -2
- package/dist/runtime/runtimeObservation.js +22 -0
- package/dist/runtime/runtimeProjection.js +10 -4
- package/dist/scheduler/leaderWakeupProcessor.js +2 -1
- package/dist/storage/compatibleTaskStore.js +36 -1
- package/dist/storage/sqliteStore.js +25 -1
- package/dist/storage/taskStore.js +48 -3
- package/dist/task/nextAction.js +1 -1
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +27 -23
- package/skills/yui-operator/SKILL.md +7 -5
package/README.md
CHANGED
|
@@ -735,7 +735,7 @@ process cleanup explicitly, and revalidates process, tmux pane, and socket
|
|
|
735
735
|
identity immediately before acting. Partial failures are reported without
|
|
736
736
|
hiding the resources that remain. Use `--all` to include discovered Yui homes.
|
|
737
737
|
|
|
738
|
-
`controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It does not stop or restart managed tmux/Agent sessions.
|
|
738
|
+
`controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It can recover a lost discovery record only when the old process still matches the current UID, Controller entrypoint, physical Home, PID, and process-start identity. It does not stop or restart managed tmux/Agent sessions.
|
|
739
739
|
|
|
740
740
|
Successful `setup`, `upgrade`, and `update` commands ensure that the current
|
|
741
741
|
Home has a running Controller, starting one when the Home was previously idle.
|
|
@@ -927,9 +927,13 @@ make install-local
|
|
|
927
927
|
|
|
928
928
|
`make install-local` writes a self-contained launcher at `output/dev/bin/yui`
|
|
929
929
|
and never touches the user-level `yui` command. The launcher resolves its own
|
|
930
|
-
checkout and defaults `YUI_HOME` to this checkout's `output/dev/home
|
|
931
|
-
|
|
932
|
-
|
|
930
|
+
checkout and defaults `YUI_HOME` to this checkout's `output/dev/home`. The
|
|
931
|
+
Controller socket is derived from that Home's durable `homeId` at the fixed
|
|
932
|
+
Linux path `/tmp/yui-<uid>/<homeId>.sock`; discovery also binds the physical
|
|
933
|
+
Home directory, so caller `TMPDIR`, path aliases, and copied runtime records
|
|
934
|
+
cannot redirect control requests. The tmux server namespace and state remain
|
|
935
|
+
scoped to the selected Home, so the checkout stays separate from other
|
|
936
|
+
checkouts and the global install.
|
|
933
937
|
It is idempotent, so re-run it after pulling new code (then run
|
|
934
938
|
`./output/dev/bin/yui controller restart` if a Controller is already running).
|
|
935
939
|
Call the launcher by its absolute path for a stable per-checkout entry point;
|
|
@@ -1802,7 +1802,7 @@ function dispatchWork(args, store, options) {
|
|
|
1802
1802
|
if (item.assignee === undefined) {
|
|
1803
1803
|
throw usageError(`Work Item has no Task Role assignee: ${item.id}. `
|
|
1804
1804
|
+ `The Task Leader must run "yui task work update ${item.id} running", `
|
|
1805
|
-
+ "then execute it directly or create
|
|
1805
|
+
+ "then execute it directly or create native subagents in the Leader Session.");
|
|
1806
1806
|
}
|
|
1807
1807
|
if (expanding && taskActor(options, task.id) !== "leader") {
|
|
1808
1808
|
throw usageError("Only the Task Leader may expand a running ExecutionGroup.");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { callController, readControllerDiscovery } from "../core/controllerClient.js";
|
|
3
|
+
import { callController, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
|
|
4
4
|
import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
|
|
5
5
|
import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
|
|
6
6
|
import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
|
|
@@ -227,8 +227,26 @@ export async function restartFileTaskController(home, options = {}) {
|
|
|
227
227
|
const call = options.call ?? callController;
|
|
228
228
|
const shutdownTimeoutMs = positive(options.shutdownTimeoutMs, SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
|
|
229
229
|
const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
let current = null;
|
|
231
|
+
let previousPid;
|
|
232
|
+
try {
|
|
233
|
+
current = await readOptionalControllerStatus(home, call);
|
|
234
|
+
previousPid = controllerPid(current);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (options.call !== undefined || !isInvalidDiscovery(error))
|
|
238
|
+
throw error;
|
|
239
|
+
// Protocol v3 is the immediately previous released Controller. Keep its
|
|
240
|
+
// parser and transport confined to this explicit replacement operation;
|
|
241
|
+
// no ordinary request can fall back to it.
|
|
242
|
+
const previous = await stopPreviousFileTaskController(home, shutdownTimeoutMs);
|
|
243
|
+
previousPid = previous.pid;
|
|
244
|
+
}
|
|
245
|
+
if (!controllerRunning(current) && options.call === undefined) {
|
|
246
|
+
const orphan = await stopOrphanedFileTaskController(home, shutdownTimeoutMs);
|
|
247
|
+
if (orphan !== undefined)
|
|
248
|
+
previousPid = orphan.pid;
|
|
249
|
+
}
|
|
232
250
|
if (controllerRunning(current)) {
|
|
233
251
|
await callFileTaskController(home, "controller.stop", {}, options);
|
|
234
252
|
const deadline = Date.now() + shutdownTimeoutMs;
|
|
@@ -555,6 +573,11 @@ function isDefinitelyNotRunning(error) {
|
|
|
555
573
|
const code = error.code;
|
|
556
574
|
return code === "CONTROLLER_NOT_RUNNING";
|
|
557
575
|
}
|
|
576
|
+
function isInvalidDiscovery(error) {
|
|
577
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
578
|
+
return false;
|
|
579
|
+
return error.code === "CONTROLLER_DISCOVERY_INVALID";
|
|
580
|
+
}
|
|
558
581
|
async function readOptionalControllerStatus(home, call) {
|
|
559
582
|
try {
|
|
560
583
|
return await call(home, "controller.status", {});
|
|
@@ -26,7 +26,8 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
|
|
|
26
26
|
import { RUNTIME_CLEANUP_REQUIRED_REASON, RUNTIME_LAUNCH_RESERVED_REASON, RUNTIME_LIFECYCLE_OWNER, hasRuntimeCleanupObligation, hasRuntimeLifecycleWork, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
27
27
|
import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
|
|
28
28
|
import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
|
|
29
|
-
import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent, runtimeObservationTaskEventPayload } from "../runtime/runtimeObservation.js";
|
|
29
|
+
import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent, runtimeObservationRunFenceMatches, runtimeObservationTaskEventPayload } from "../runtime/runtimeObservation.js";
|
|
30
|
+
import { projectRuntimeTaskEvents } from "../runtime/runtimeProjection.js";
|
|
30
31
|
/** Maps the authoritative FileTaskStore records to the scheduler's narrow port. */
|
|
31
32
|
export class FileSchedulerStoreAdapter {
|
|
32
33
|
store;
|
|
@@ -80,6 +81,13 @@ export class FileSchedulerStoreAdapter {
|
|
|
80
81
|
const classification = this.classifyRuntimeTurnCompleted(completed);
|
|
81
82
|
if (classification !== "apply")
|
|
82
83
|
return classification;
|
|
84
|
+
if (this.runtimeTurnHasActiveNativeSubagents(input)) {
|
|
85
|
+
// This is an intermediate provider Turn boundary, not the end of the
|
|
86
|
+
// durable Yui Run. The provider will deliver child completion
|
|
87
|
+
// notifications as later native Turns in the same Run generation.
|
|
88
|
+
outcome = this.validateCanonicalRunObservation(input, now);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
83
91
|
const result = this.observeRuntimeTurnCompleted(completed, now);
|
|
84
92
|
outcome = result.disposition === "obsolete" ? "obsolete" : "applied";
|
|
85
93
|
break;
|
|
@@ -180,6 +188,14 @@ export class FileSchedulerStoreAdapter {
|
|
|
180
188
|
return "applied";
|
|
181
189
|
});
|
|
182
190
|
}
|
|
191
|
+
runtimeTurnHasActiveNativeSubagents(input) {
|
|
192
|
+
const taskId = input.fence.taskId;
|
|
193
|
+
const run = this.store.getAgentRun(taskId, input.fence.runId);
|
|
194
|
+
if (run === null)
|
|
195
|
+
return false;
|
|
196
|
+
const projection = projectRuntimeTaskEvents(input.fence, run.createdAt, this.store.listEvents(taskId));
|
|
197
|
+
return Object.values(projection.operations).some(({ kind }) => kind === "subagent");
|
|
198
|
+
}
|
|
183
199
|
validateCanonicalSessionObservation(input, now) {
|
|
184
200
|
return this.store.transaction((store) => {
|
|
185
201
|
const run = store.getAgentRun(input.fence.taskId, input.fence.runId);
|
|
@@ -1249,7 +1265,8 @@ export class FileSchedulerStoreAdapter {
|
|
|
1249
1265
|
});
|
|
1250
1266
|
}
|
|
1251
1267
|
/**
|
|
1252
|
-
* Fast hook path:
|
|
1268
|
+
* Fast hook path: validates the native Turn boundary before it is either
|
|
1269
|
+
* retained as an intermediate child wait or given a two-second workflow
|
|
1253
1270
|
* closure deadline. It never performs tmux, workspace, or Controller I/O.
|
|
1254
1271
|
*/
|
|
1255
1272
|
classifyRuntimeTurnCompleted(input) {
|
|
@@ -2808,8 +2825,13 @@ function runtimeObservationTelemetryEntry(input) {
|
|
|
2808
2825
|
function compactedRuntimeObservationIds(events, incoming) {
|
|
2809
2826
|
const existing = events.flatMap((event) => {
|
|
2810
2827
|
const observation = runtimeObservationFromTaskEvent(event);
|
|
2828
|
+
const matches = incoming.kind.startsWith("operation.")
|
|
2829
|
+
? observation !== null
|
|
2830
|
+
&& runtimeObservationRunFenceMatches(observation.fence, incoming.fence)
|
|
2831
|
+
: observation !== null
|
|
2832
|
+
&& runtimeObservationFenceMatches(observation.fence, incoming.fence);
|
|
2811
2833
|
return observation !== null
|
|
2812
|
-
&&
|
|
2834
|
+
&& matches
|
|
2813
2835
|
? [{ event, observation }]
|
|
2814
2836
|
: [];
|
|
2815
2837
|
});
|
|
@@ -2838,7 +2860,8 @@ function compactedRuntimeObservationIds(events, incoming) {
|
|
|
2838
2860
|
&& observation.payload.sourceId === incoming.payload.sourceId;
|
|
2839
2861
|
}
|
|
2840
2862
|
if (["turn.completed", "turn.failed", "turn.cancelled"].includes(incoming.kind)) {
|
|
2841
|
-
return observation.kind.startsWith("operation.")
|
|
2863
|
+
return (observation.kind.startsWith("operation.")
|
|
2864
|
+
&& observation.payload.operation !== "subagent")
|
|
2842
2865
|
|| observation.kind === "turn.waiting"
|
|
2843
2866
|
|| observation.kind === "turn.completed"
|
|
2844
2867
|
|| observation.kind === "turn.failed"
|
|
@@ -257,8 +257,9 @@ function assertOwnedArtifactPath(resource, environment) {
|
|
|
257
257
|
}
|
|
258
258
|
const home = resource.yuiHome;
|
|
259
259
|
if (home !== undefined
|
|
260
|
+
&& resource.homeId !== undefined
|
|
260
261
|
&& artifact.artifactKind === "controller-socket"
|
|
261
|
-
&& path === controllerSocketPath(
|
|
262
|
+
&& path === controllerSocketPath(resource.homeId)) {
|
|
262
263
|
return;
|
|
263
264
|
}
|
|
264
265
|
if (home !== undefined
|
|
@@ -174,7 +174,7 @@ export function buildControllerResourceInventory(facts) {
|
|
|
174
174
|
? homeFact.artifacts
|
|
175
175
|
: [discoveryArtifact, ...homeFact.artifacts.filter((artifact) => (artifact.path !== discoveryArtifact.path))];
|
|
176
176
|
for (const artifact of artifacts) {
|
|
177
|
-
resources.push(artifactResource(artifact, yuiHome, homeFact.domain));
|
|
177
|
+
resources.push(artifactResource(artifact, yuiHome, homeFact.domain, homeFact.homeId));
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
for (const artifact of facts.globalArtifacts) {
|
|
@@ -186,6 +186,7 @@ export function buildControllerResourceInventory(facts) {
|
|
|
186
186
|
const owned = ordered.filter((resource) => resource.yuiHome === yuiHome);
|
|
187
187
|
return {
|
|
188
188
|
yuiHome,
|
|
189
|
+
...(home.homeId === undefined ? {} : { homeId: home.homeId }),
|
|
189
190
|
storageStatus: home.storageStatus,
|
|
190
191
|
resourceCount: owned.length,
|
|
191
192
|
liveProcessCount: uniqueProcesses(owned).length,
|
|
@@ -332,7 +333,7 @@ function processResource(input) {
|
|
|
332
333
|
...(input.domain === undefined ? {} : { domain: input.domain })
|
|
333
334
|
};
|
|
334
335
|
}
|
|
335
|
-
function artifactResource(artifact, yuiHome, domain) {
|
|
336
|
+
function artifactResource(artifact, yuiHome, domain, homeId) {
|
|
336
337
|
const stale = !artifact.active;
|
|
337
338
|
const baseDisposition = stale ? "safe" : "report-only";
|
|
338
339
|
const disposition = domainDisposition(baseDisposition, undefined, domain);
|
|
@@ -346,6 +347,7 @@ function artifactResource(artifact, yuiHome, domain) {
|
|
|
346
347
|
disposition,
|
|
347
348
|
reasonCode: domainReason(stale ? `stale-${artifact.artifactKind}` : `active-unmapped-${artifact.artifactKind}`, domain, disposition),
|
|
348
349
|
...(yuiHome === undefined ? {} : { yuiHome }),
|
|
350
|
+
...(homeId === undefined ? {} : { homeId }),
|
|
349
351
|
owner: yuiHome === undefined
|
|
350
352
|
? { kind: "none" }
|
|
351
353
|
: { kind: "controller-domain", yuiHome },
|
|
@@ -10,6 +10,7 @@ import { tmuxSocketDirectory, tmuxSocketEnvironment } from "../tmux/tmuxSocketEn
|
|
|
10
10
|
import { readControllerDiscovery } from "../core/controllerClient.js";
|
|
11
11
|
import { CONTROLLER_DISCOVERY_PATH } from "../core/protocol.js";
|
|
12
12
|
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
13
|
+
import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
|
|
13
14
|
import { buildControllerResourceInventory } from "./resourceInventory.js";
|
|
14
15
|
import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "./domainIdentity.js";
|
|
15
16
|
const LINUX_PROCESS_SCAN_BATCH_SIZE = 64;
|
|
@@ -20,8 +21,7 @@ export async function scanControllerResourceInventory(options) {
|
|
|
20
21
|
const observedAt = (options.now ?? (() => new Date()))();
|
|
21
22
|
const warnings = [];
|
|
22
23
|
const activeSockets = readActiveUnixSocketPaths(warnings);
|
|
23
|
-
const processes = (await listLinuxProcesses(warnings))
|
|
24
|
-
.filter(({ pid }) => pid !== process.pid);
|
|
24
|
+
const processes = normalizePhysicalHomeAliases((await listLinuxProcesses(warnings)).filter(({ pid }) => pid !== process.pid), currentHome);
|
|
25
25
|
const homes = new Set([currentHome]);
|
|
26
26
|
if (options.scope === "all") {
|
|
27
27
|
for (const process of processes) {
|
|
@@ -88,19 +88,23 @@ export async function scanControllerResourceInventory(options) {
|
|
|
88
88
|
|| (discovery.pid === process.pid
|
|
89
89
|
&& readLinuxProcessStartIdentity(process.pid) === discovery.processStartIdentity));
|
|
90
90
|
const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
|
|
91
|
-
const expectedControllerSocketPath =
|
|
91
|
+
const expectedControllerSocketPath = state.homeId === undefined
|
|
92
|
+
? undefined
|
|
93
|
+
: controllerSocketPath(state.homeId);
|
|
92
94
|
if (discovery.status === "valid"
|
|
93
95
|
&& !validDiscoveryProcess
|
|
94
96
|
&& existsSync(discoveryPath)) {
|
|
95
97
|
artifacts.push(fileArtifact(discoveryPath, "controller-discovery", false));
|
|
96
98
|
}
|
|
97
|
-
if (
|
|
99
|
+
if (expectedControllerSocketPath !== undefined
|
|
100
|
+
&& existsSync(expectedControllerSocketPath)
|
|
98
101
|
&& !activeSockets.has(expectedControllerSocketPath)
|
|
99
102
|
&& !validDiscoveryProcess) {
|
|
100
103
|
artifacts.push(fileArtifact(expectedControllerSocketPath, "controller-socket", false));
|
|
101
104
|
}
|
|
102
105
|
homeFacts.push({
|
|
103
106
|
yuiHome: home,
|
|
107
|
+
...(state.homeId === undefined ? {} : { homeId: state.homeId }),
|
|
104
108
|
exists: existsSync(home),
|
|
105
109
|
storageStatus: state.storageStatus,
|
|
106
110
|
discovery,
|
|
@@ -203,6 +207,9 @@ async function listLinuxProcesses(warnings) {
|
|
|
203
207
|
const args = splitNullDelimited(readFileSync(`/proc/${pid}/cmdline`));
|
|
204
208
|
const command = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
|
|
205
209
|
const yuiHome = readYuiHome(pid);
|
|
210
|
+
const homeFilesystemId = yuiHome === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: optionalHomeFilesystemId(yuiHome);
|
|
206
213
|
const io = readProcessIo(pid);
|
|
207
214
|
result.push({
|
|
208
215
|
pid,
|
|
@@ -210,6 +217,7 @@ async function listLinuxProcesses(warnings) {
|
|
|
210
217
|
uid: processUid,
|
|
211
218
|
startIdentity: parsed.startIdentity,
|
|
212
219
|
...(yuiHome === undefined ? {} : { yuiHome }),
|
|
220
|
+
...(homeFilesystemId === undefined ? {} : { homeFilesystemId }),
|
|
213
221
|
kind: classifyRuntimeProcess(args, command),
|
|
214
222
|
command,
|
|
215
223
|
args,
|
|
@@ -227,6 +235,47 @@ async function listLinuxProcesses(warnings) {
|
|
|
227
235
|
});
|
|
228
236
|
return result;
|
|
229
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Process environments retain their launch-time path spelling. Collapse
|
|
240
|
+
* symlink and bind-mount aliases by physical directory identity so inventory,
|
|
241
|
+
* update reconciliation, and cleanup all attribute them to one Home.
|
|
242
|
+
*/
|
|
243
|
+
function normalizePhysicalHomeAliases(processes, currentHome) {
|
|
244
|
+
const currentFilesystemId = optionalHomeFilesystemId(currentHome);
|
|
245
|
+
const representatives = new Map();
|
|
246
|
+
if (currentFilesystemId !== undefined) {
|
|
247
|
+
representatives.set(currentFilesystemId, currentHome);
|
|
248
|
+
}
|
|
249
|
+
for (const processFact of processes) {
|
|
250
|
+
const filesystemId = processFact.homeFilesystemId;
|
|
251
|
+
const yuiHome = processFact.yuiHome;
|
|
252
|
+
if (filesystemId === undefined
|
|
253
|
+
|| yuiHome === undefined
|
|
254
|
+
|| filesystemId === currentFilesystemId)
|
|
255
|
+
continue;
|
|
256
|
+
const existing = representatives.get(filesystemId);
|
|
257
|
+
if (existing === undefined || yuiHome.localeCompare(existing) < 0) {
|
|
258
|
+
representatives.set(filesystemId, yuiHome);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return processes.map((processFact) => {
|
|
262
|
+
const filesystemId = processFact.homeFilesystemId;
|
|
263
|
+
const representative = filesystemId === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: representatives.get(filesystemId);
|
|
266
|
+
return representative === undefined || representative === processFact.yuiHome
|
|
267
|
+
? processFact
|
|
268
|
+
: { ...processFact, yuiHome: representative };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function optionalHomeFilesystemId(home) {
|
|
272
|
+
try {
|
|
273
|
+
return readHomeFilesystemId(home);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
230
279
|
/** Cooperatively visits synchronous inventory entries in bounded turns. */
|
|
231
280
|
export async function forEachInEventLoopBatches(entries, batchSize, visit, timing = {}) {
|
|
232
281
|
if (!Number.isSafeInteger(batchSize) || batchSize < 1) {
|
|
@@ -372,6 +421,10 @@ async function inspectDiscovery(home, processes, activeSockets) {
|
|
|
372
421
|
const discovery = await readControllerDiscovery(home);
|
|
373
422
|
return {
|
|
374
423
|
status: "valid",
|
|
424
|
+
protocolVersion: discovery.protocolVersion,
|
|
425
|
+
homeId: discovery.homeId,
|
|
426
|
+
homeFilesystemId: discovery.homeFilesystemId,
|
|
427
|
+
controllerInstanceId: discovery.controllerInstanceId,
|
|
375
428
|
pid: discovery.pid,
|
|
376
429
|
processStartIdentity: discovery.processStartIdentity,
|
|
377
430
|
socketPath: discovery.socketPath,
|
|
@@ -659,7 +712,11 @@ function loadHomeState(home, warnings, options) {
|
|
|
659
712
|
}
|
|
660
713
|
}
|
|
661
714
|
}
|
|
662
|
-
return {
|
|
715
|
+
return {
|
|
716
|
+
storageStatus: schema.status,
|
|
717
|
+
homeId: store.getHomeIdentity().homeId,
|
|
718
|
+
roles
|
|
719
|
+
};
|
|
663
720
|
}
|
|
664
721
|
catch (error) {
|
|
665
722
|
warnings.push(`Cannot load runtime ownership for ${home}: ${message(error)}`);
|
|
@@ -84,6 +84,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
84
84
|
// the same WAL db and serialize via BEGIN IMMEDIATE + busy_timeout.
|
|
85
85
|
? new SqliteTaskStore(home)
|
|
86
86
|
: openCompatibleFileTaskStore(home));
|
|
87
|
+
const homeId = store.getHomeIdentity().homeId;
|
|
87
88
|
// When the worker backend is active, the db-touching observer folds run in
|
|
88
89
|
// the worker (off the main event loop). The client is closed on shutdown.
|
|
89
90
|
const asyncStoreClient = useWorker
|
|
@@ -147,7 +148,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
147
148
|
runtimeRoot: `${resolve(home)}.task-runtimes`,
|
|
148
149
|
controlPlane: {
|
|
149
150
|
yuiHome: home,
|
|
150
|
-
controllerSocketPath: controllerSocketPath(
|
|
151
|
+
controllerSocketPath: controllerSocketPath(homeId),
|
|
151
152
|
tmuxNamespace: yuiTmuxServerName(home),
|
|
152
153
|
globalInstallPaths: [process.execPath]
|
|
153
154
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { lstat, readFile } from "node:fs/promises";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { CONTROLLER_DISCOVERY_PATH, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
|
|
6
6
|
import { isControllerSocketPathForHome } from "./controllerEndpoint.js";
|
|
7
|
+
import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
|
|
8
|
+
import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "./controllerProcessIdentity.js";
|
|
7
9
|
export class ControllerClientError extends Error {
|
|
8
10
|
code;
|
|
9
11
|
constructor(code, message) {
|
|
@@ -24,8 +26,11 @@ export async function readControllerDiscovery(home) {
|
|
|
24
26
|
throw invalidDiscovery();
|
|
25
27
|
}
|
|
26
28
|
const value = JSON.parse(await readFile(discoveryPath, "utf8"));
|
|
27
|
-
const socketPath = discoverySocketPath(
|
|
28
|
-
return parseControllerDiscovery(value,
|
|
29
|
+
const socketPath = discoverySocketPath(value);
|
|
30
|
+
return parseControllerDiscovery(value, {
|
|
31
|
+
homeFilesystemId: readHomeFilesystemId(home),
|
|
32
|
+
socketPath
|
|
33
|
+
});
|
|
29
34
|
}
|
|
30
35
|
catch (error) {
|
|
31
36
|
if (error instanceof ControllerClientError)
|
|
@@ -36,13 +41,15 @@ export async function readControllerDiscovery(home) {
|
|
|
36
41
|
throw invalidDiscovery();
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
|
-
function discoverySocketPath(
|
|
44
|
+
function discoverySocketPath(value) {
|
|
40
45
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
41
46
|
throw invalidDiscovery();
|
|
42
47
|
}
|
|
48
|
+
const homeId = Reflect.get(value, "homeId");
|
|
43
49
|
const socketPath = Reflect.get(value, "socketPath");
|
|
44
|
-
if (typeof
|
|
45
|
-
||
|
|
50
|
+
if (typeof homeId !== "string"
|
|
51
|
+
|| typeof socketPath !== "string"
|
|
52
|
+
|| !isControllerSocketPathForHome(homeId, socketPath)) {
|
|
46
53
|
throw invalidDiscovery();
|
|
47
54
|
}
|
|
48
55
|
return socketPath;
|
|
@@ -59,6 +66,10 @@ export async function callController(home, method, params = {}, options = {}) {
|
|
|
59
66
|
requestLine = encodeControllerRequest({
|
|
60
67
|
id,
|
|
61
68
|
token: discovery.token,
|
|
69
|
+
protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
70
|
+
homeId: discovery.homeId,
|
|
71
|
+
homeFilesystemId: discovery.homeFilesystemId,
|
|
72
|
+
controllerInstanceId: discovery.controllerInstanceId,
|
|
62
73
|
method,
|
|
63
74
|
params
|
|
64
75
|
});
|
|
@@ -71,6 +82,129 @@ export async function callController(home, method, params = {}, options = {}) {
|
|
|
71
82
|
}
|
|
72
83
|
return exchange(discovery.socketPath, requestLine, id, timeoutMs);
|
|
73
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* One-generation transition used only by the documented `controller restart`
|
|
87
|
+
* upgrade path from the last released protocol (v3). Ordinary calls never
|
|
88
|
+
* accept or dispatch through this legacy shape.
|
|
89
|
+
*/
|
|
90
|
+
export async function stopPreviousFileTaskController(home, timeoutMs) {
|
|
91
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
92
|
+
throw new TypeError("Controller timeout must be a positive integer.");
|
|
93
|
+
}
|
|
94
|
+
const discovery = await readPreviousControllerDiscovery(home);
|
|
95
|
+
const homeFilesystemId = readHomeFilesystemId(home);
|
|
96
|
+
if (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) === undefined)
|
|
97
|
+
throw invalidDiscovery();
|
|
98
|
+
const id = randomUUID();
|
|
99
|
+
const requestLine = `${JSON.stringify({
|
|
100
|
+
id,
|
|
101
|
+
token: discovery.token,
|
|
102
|
+
method: "controller.stop",
|
|
103
|
+
params: { expectedPid: discovery.pid }
|
|
104
|
+
})}\n`;
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
await exchange(discovery.socketPath, requestLine, id, timeoutMs);
|
|
107
|
+
while (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) !== undefined) {
|
|
108
|
+
if (Date.now() >= deadline) {
|
|
109
|
+
throw new ControllerClientError("CONTROLLER_TIMEOUT", `Previous Controller did not stop within ${timeoutMs} ms.`);
|
|
110
|
+
}
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
112
|
+
}
|
|
113
|
+
return Object.freeze({ pid: discovery.pid });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Explicit restart recovery for an exact Controller whose discovery record
|
|
117
|
+
* was lost. The signal is fenced by same UID, controller entrypoint, physical
|
|
118
|
+
* Home identity, PID, and process-start identity; an unprovable process is
|
|
119
|
+
* never touched.
|
|
120
|
+
*/
|
|
121
|
+
export async function stopOrphanedFileTaskController(home, timeoutMs) {
|
|
122
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
123
|
+
throw new TypeError("Controller timeout must be a positive integer.");
|
|
124
|
+
}
|
|
125
|
+
const homeFilesystemId = readHomeFilesystemId(home);
|
|
126
|
+
const candidate = findLiveControllerProcessForHome(homeFilesystemId);
|
|
127
|
+
if (candidate === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
if (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) === undefined)
|
|
130
|
+
return undefined;
|
|
131
|
+
try {
|
|
132
|
+
process.kill(candidate.pid, "SIGTERM");
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (isNodeError(error) && error.code === "ESRCH")
|
|
136
|
+
return undefined;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
const deadline = Date.now() + timeoutMs;
|
|
140
|
+
while (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) !== undefined) {
|
|
141
|
+
if (Date.now() >= deadline) {
|
|
142
|
+
throw new ControllerClientError("CONTROLLER_TIMEOUT", `Orphaned Controller did not stop within ${timeoutMs} ms.`);
|
|
143
|
+
}
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
145
|
+
}
|
|
146
|
+
return Object.freeze({ pid: candidate.pid });
|
|
147
|
+
}
|
|
148
|
+
async function readPreviousControllerDiscovery(home) {
|
|
149
|
+
const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
|
|
150
|
+
try {
|
|
151
|
+
const metadata = await lstat(discoveryPath);
|
|
152
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
153
|
+
if (!metadata.isFile()
|
|
154
|
+
|| (metadata.mode & 0o077) !== 0
|
|
155
|
+
|| metadata.size > 4_096
|
|
156
|
+
|| (uid !== undefined && metadata.uid !== uid))
|
|
157
|
+
throw invalidDiscovery();
|
|
158
|
+
const value = JSON.parse(await readFile(discoveryPath, "utf8"));
|
|
159
|
+
if (typeof value !== "object"
|
|
160
|
+
|| value === null
|
|
161
|
+
|| Array.isArray(value)
|
|
162
|
+
|| !hasExactStringKeys(value, [
|
|
163
|
+
"pid",
|
|
164
|
+
"processStartIdentity",
|
|
165
|
+
"socketPath",
|
|
166
|
+
"token"
|
|
167
|
+
]))
|
|
168
|
+
throw invalidDiscovery();
|
|
169
|
+
const record = value;
|
|
170
|
+
if (!Number.isSafeInteger(record.pid)
|
|
171
|
+
|| record.pid < 1
|
|
172
|
+
|| typeof record.processStartIdentity !== "string"
|
|
173
|
+
|| !/^[0-9]{1,32}$/u.test(record.processStartIdentity)
|
|
174
|
+
|| typeof record.socketPath !== "string"
|
|
175
|
+
|| !isPreviousControllerSocketPath(record.socketPath)
|
|
176
|
+
|| typeof record.token !== "string"
|
|
177
|
+
|| !/^[a-f0-9]{64}$/u.test(record.token))
|
|
178
|
+
throw invalidDiscovery();
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
pid: record.pid,
|
|
181
|
+
processStartIdentity: record.processStartIdentity,
|
|
182
|
+
socketPath: record.socketPath,
|
|
183
|
+
token: record.token
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof ControllerClientError)
|
|
188
|
+
throw error;
|
|
189
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
190
|
+
throw new ControllerClientError("CONTROLLER_NOT_RUNNING", "Controller is not running.");
|
|
191
|
+
}
|
|
192
|
+
throw invalidDiscovery();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function hasExactStringKeys(value, expected) {
|
|
196
|
+
const keys = Reflect.ownKeys(value);
|
|
197
|
+
return keys.length === expected.length
|
|
198
|
+
&& keys.every((key) => typeof key === "string")
|
|
199
|
+
&& expected.every((key) => Object.hasOwn(value, key));
|
|
200
|
+
}
|
|
201
|
+
function isPreviousControllerSocketPath(candidate) {
|
|
202
|
+
if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
|
|
203
|
+
return false;
|
|
204
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
205
|
+
return basename(dirname(candidate)) === `yui-${uid}`
|
|
206
|
+
&& /^[a-f0-9]{24}\.sock$/u.test(basename(candidate));
|
|
207
|
+
}
|
|
74
208
|
function exchange(socketPath, requestLine, expectedId, timeoutMs) {
|
|
75
209
|
return new Promise((resolve, reject) => {
|
|
76
210
|
const socket = createConnection(socketPath);
|
|
@@ -1,37 +1,25 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { tmpdir } from "node:os";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
export function controllerSocketPath(
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { validateHomeId } from "../repository/homeIdentity.js";
|
|
4
|
+
export function controllerSocketPath(homeId) {
|
|
5
|
+
const identity = validateHomeId(homeId);
|
|
6
6
|
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
7
|
-
const socketName = `${
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
// Linux limits Unix-socket paths to a small fixed budget. Managed Task
|
|
14
|
-
// runtimes intentionally use isolated TMPDIR roots, which may themselves
|
|
15
|
-
// be nested deeply enough to exhaust that budget. Keep the Home/uid/name
|
|
16
|
-
// fence while using the compact system temporary root only for this
|
|
17
|
-
// exceptional path; clients follow the Home-owned discovery record.
|
|
18
|
-
return join("/tmp", `yui-${uid}`, socketName);
|
|
7
|
+
const socketName = `${identity}.sock`;
|
|
8
|
+
// Linux Unix-socket paths have a small fixed budget. A stable per-uid system
|
|
9
|
+
// root is both short and independent of each caller's isolated TMPDIR.
|
|
10
|
+
const root = process.platform === "linux" ? "/tmp" : tmpdir();
|
|
11
|
+
return join(root, `yui-${uid}`, socketName);
|
|
19
12
|
}
|
|
20
13
|
/**
|
|
21
|
-
* A protected Home discovery record owns the Controller endpoint.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
14
|
+
* A protected Home discovery record owns the Controller endpoint. Linux uses
|
|
15
|
+
* one fixed root, so every caller validates the exact Home/uid endpoint
|
|
16
|
+
* independently of its own TMPDIR.
|
|
24
17
|
*/
|
|
25
|
-
export function isControllerSocketPathForHome(
|
|
26
|
-
|
|
18
|
+
export function isControllerSocketPathForHome(homeId, candidate) {
|
|
19
|
+
try {
|
|
20
|
+
return candidate === controllerSocketPath(validateHomeId(homeId));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
27
23
|
return false;
|
|
28
|
-
|
|
29
|
-
return basename(dirname(candidate)) === `yui-${uid}`
|
|
30
|
-
&& basename(candidate) === `${controllerSocketIdentity(home)}.sock`;
|
|
31
|
-
}
|
|
32
|
-
function controllerSocketIdentity(home) {
|
|
33
|
-
return createHash("sha256")
|
|
34
|
-
.update(resolve(home))
|
|
35
|
-
.digest("hex")
|
|
36
|
-
.slice(0, 24);
|
|
24
|
+
}
|
|
37
25
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
|
|
3
|
+
import { readHomeFilesystemId, validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
|
|
4
|
+
/** Exact live-process fence used only at Controller startup and v3 handoff. */
|
|
5
|
+
export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProcessStartIdentity) {
|
|
6
|
+
if (!Number.isSafeInteger(pid) || pid < 1 || pid === process.pid)
|
|
7
|
+
return undefined;
|
|
8
|
+
const expectedFilesystemId = validateHomeFilesystemId(homeFilesystemId);
|
|
9
|
+
try {
|
|
10
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
11
|
+
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
12
|
+
const processUid = /^Uid:\s+([0-9]+)/mu.exec(status)?.[1];
|
|
13
|
+
if (processUid === undefined || Number(processUid) !== uid)
|
|
14
|
+
return undefined;
|
|
15
|
+
const args = readFileSync(`/proc/${pid}/cmdline`)
|
|
16
|
+
.toString("utf8")
|
|
17
|
+
.split("\0")
|
|
18
|
+
.filter((argument) => argument.length > 0);
|
|
19
|
+
if (!args.some((argument) => /(?:^|\/)controllerMain\.js$/u.test(argument))) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const environment = readFileSync(`/proc/${pid}/environ`)
|
|
23
|
+
.toString("utf8")
|
|
24
|
+
.split("\0");
|
|
25
|
+
const yuiHome = environment
|
|
26
|
+
.find((entry) => entry.startsWith("YUI_HOME="))
|
|
27
|
+
?.slice("YUI_HOME=".length);
|
|
28
|
+
if (yuiHome === undefined || readHomeFilesystemId(yuiHome) !== expectedFilesystemId) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const processStartIdentity = readLinuxProcessStartIdentity(pid);
|
|
32
|
+
if (processStartIdentity === undefined
|
|
33
|
+
|| (expectedProcessStartIdentity !== undefined
|
|
34
|
+
&& processStartIdentity !== expectedProcessStartIdentity))
|
|
35
|
+
return undefined;
|
|
36
|
+
return Object.freeze({ pid, processStartIdentity });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Finds an older/current Controller whose discovery record was lost. */
|
|
43
|
+
export function findLiveControllerProcessForHome(homeFilesystemId) {
|
|
44
|
+
validateHomeFilesystemId(homeFilesystemId);
|
|
45
|
+
if (process.platform !== "linux")
|
|
46
|
+
return undefined;
|
|
47
|
+
const entries = readdirSync("/proc", { encoding: "utf8" });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (!/^[1-9][0-9]*$/u.test(entry))
|
|
50
|
+
continue;
|
|
51
|
+
const match = inspectLiveControllerProcess(Number(entry), homeFilesystemId);
|
|
52
|
+
if (match !== undefined)
|
|
53
|
+
return match;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|