@zq-silk/yui 0.6.0 → 0.6.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 +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableJob supervision: the Controller-side reconciliation ladder.
|
|
3
|
+
*
|
|
4
|
+
* The supervisor is pure logic over injected ports. It is invoked from the
|
|
5
|
+
* Controller scheduler pass and on Controller startup. Each pass reconciles
|
|
6
|
+
* every job toward its correct state:
|
|
7
|
+
*
|
|
8
|
+
* queued -> write spec, spawn detached runner, transition to running
|
|
9
|
+
* running -> harvest exit.json / liveness via pid+startIdentity /
|
|
10
|
+
* evidence ladder (exit.json -> checkpoint -> unknown) /
|
|
11
|
+
* stale heartbeat SIGTERM then SIGKILL escalation
|
|
12
|
+
* terminal + !wakeupNotified -> set flag + enqueue Leader wakeup
|
|
13
|
+
* cancelRequestedAt -> write cancel fence + SIGTERM
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
20
|
+
import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, startDurableJob, touchDurableJobHeartbeat } from "../job/durableJob.js";
|
|
21
|
+
import { readLinuxProcessStartIdentity } from "./domainIdentity.js";
|
|
22
|
+
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
|
|
23
|
+
const HEARTBEAT_STALE_MS = 2 * 60_000;
|
|
24
|
+
const SIGKILL_GRACE_MS = 30_000;
|
|
25
|
+
export class DurableJobSupervisor {
|
|
26
|
+
#store;
|
|
27
|
+
#process;
|
|
28
|
+
#artifacts;
|
|
29
|
+
#terminalEvents;
|
|
30
|
+
#wake;
|
|
31
|
+
#onError;
|
|
32
|
+
// f5: Composite key (taskId/jobId) because job IDs are Task-local — every
|
|
33
|
+
// Task has a job-1, so a Task-local key would cross-kill healthy runners.
|
|
34
|
+
#sigkillAt = new Map();
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.#store = options.store;
|
|
37
|
+
this.#process = options.process;
|
|
38
|
+
this.#artifacts = options.artifacts;
|
|
39
|
+
this.#terminalEvents = options.terminalEvents;
|
|
40
|
+
this.#wake = options.wake ?? (() => undefined);
|
|
41
|
+
this.#onError = options.onError ?? (() => undefined);
|
|
42
|
+
}
|
|
43
|
+
reconcile(now) {
|
|
44
|
+
const jobs = this.#store.listAllDurableJobs();
|
|
45
|
+
for (const job of jobs) {
|
|
46
|
+
try {
|
|
47
|
+
this.#reconcileJob(job, now);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
this.#onError(error instanceof Error
|
|
51
|
+
? error
|
|
52
|
+
: new Error(`DurableJob supervision failed for ${job.id}: ${String(error)}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
#reconcileJob(job, now) {
|
|
57
|
+
if (job.status === "queued") {
|
|
58
|
+
this.#reconcileQueued(job, now);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (job.status === "running") {
|
|
62
|
+
this.#superviseRunning(job, now);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (isDurableJobTerminal(job.status) && job.wakeupNotified !== true) {
|
|
66
|
+
this.#notifyWakeup(job, now);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Composite key for the SIGKILL deadline map. */
|
|
70
|
+
#sigkillKey(job) {
|
|
71
|
+
return `${job.taskId}/${job.id}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reconcile a queued job.
|
|
75
|
+
*
|
|
76
|
+
* f4: A queued job with a cancel request converges to `cancelled` without
|
|
77
|
+
* spawning a runner — but only if no runner was already spawned. If a
|
|
78
|
+
* start marker proves a runner exists (real pid, or pending marker +
|
|
79
|
+
* ready.json), the job is adopted to running first; the running-cancel
|
|
80
|
+
* path then fences and signals it. Cancelling a spawned job from queued
|
|
81
|
+
* would orphan the runner.
|
|
82
|
+
*
|
|
83
|
+
* f3: The normal path writes a pending start marker, spawns the runner, and
|
|
84
|
+
* lets the runner's own `ready.json` handshake prove it started before any
|
|
85
|
+
* side effect. On recovery the supervisor adopts queued→running first
|
|
86
|
+
* (harvest/unknown require `running`), then harvests exit or handles a
|
|
87
|
+
* dead process — never calling complete/unknown directly from `queued`.
|
|
88
|
+
*/
|
|
89
|
+
#reconcileQueued(job, now) {
|
|
90
|
+
const marker = this.#artifacts.readStartMarker(job.taskId, job.id);
|
|
91
|
+
// f1/rr5: A cancel request on a queued job must not orphan an already-
|
|
92
|
+
// spawned runner. Check spawn evidence before converging to cancelled.
|
|
93
|
+
if (job.cancelRequestedAt !== undefined) {
|
|
94
|
+
this.#artifacts.writeCancelFence(job.taskId, job.id);
|
|
95
|
+
const spawnedProcess = this.#spawnedProcessFromEvidence(job, marker);
|
|
96
|
+
if (spawnedProcess !== null) {
|
|
97
|
+
// f1/rr5: A runner was spawned. Signal it in THIS reconcile pass —
|
|
98
|
+
// not adopt to running and wait for the next pass. The signal is
|
|
99
|
+
// sent before the adoption so the runner begins draining
|
|
100
|
+
// immediately; the adoption preserves evidence (exit.json / dead-
|
|
101
|
+
// process handling converges the job on this or the next pass).
|
|
102
|
+
this.#process.signalIfOwned(spawnedProcess.pid, spawnedProcess.startIdentity, "SIGTERM");
|
|
103
|
+
this.#adoptAndContinue(job, spawnedProcess, now);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (marker !== null) {
|
|
107
|
+
// f1/rr5: Ambiguous spawn — a pending marker exists but ready.json
|
|
108
|
+
// does not. The runner may be starting (slow to write ready.json)
|
|
109
|
+
// or may never have started. Do NOT terminalize without signaling.
|
|
110
|
+
// Re-spawn: the new runner writes ready.json, observes the cancel
|
|
111
|
+
// fence, and exits as cancelled without side effects. The next
|
|
112
|
+
// pass harvests the cancelled exit.json.
|
|
113
|
+
this.#startJob(job, now);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// No spawn attempted — safe to cancel from queued.
|
|
117
|
+
// f6/rr5: The terminal transition and the Leader wakeup must be
|
|
118
|
+
// atomic (same transaction). Compose cancel + wakeupNotified and
|
|
119
|
+
// pass the wakeup param so the adapter enqueues the Leader mailbox
|
|
120
|
+
// entry in the same transaction.
|
|
121
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
|
|
122
|
+
this.#deliverTerminalEvent(terminal);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (marker === null) {
|
|
126
|
+
this.#startJob(job, now);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// f3: A pending marker means the Controller died after writing the marker
|
|
130
|
+
// but before the runner proved it started. Check the runner's ready file.
|
|
131
|
+
if (marker.startIdentity === "pending") {
|
|
132
|
+
const ready = this.#artifacts.readReadyFile(job.taskId, job.id);
|
|
133
|
+
if (ready === null) {
|
|
134
|
+
// The runner either never started or died before writing ready.
|
|
135
|
+
// No side effects could have occurred — re-spawn safely.
|
|
136
|
+
this.#startJob(job, now);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Runner proved it started: adopt and continue from evidence.
|
|
140
|
+
// rr4/finding-4: Use the runner's own startIdentity from ready.json,
|
|
141
|
+
// not a fresh /proc read (which could return a reused PID's identity).
|
|
142
|
+
this.#adoptAndContinue(job, { pid: ready.pid, startIdentity: ready.startIdentity }, now);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Marker with a real pid (already spawned). Adopt and continue.
|
|
146
|
+
// f3/rr5: ready.json is authoritative when both exist.
|
|
147
|
+
const spawned = this.#spawnedProcessFromEvidence(job, marker);
|
|
148
|
+
this.#adoptAndContinue(job, spawned ?? { pid: marker.pid, startIdentity: marker.startIdentity }, now);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Determine whether a runner was spawned for this job, based on durable
|
|
152
|
+
* evidence. Returns the process identity if spawned, null otherwise.
|
|
153
|
+
*/
|
|
154
|
+
#spawnedProcessFromEvidence(job, marker) {
|
|
155
|
+
if (marker === null)
|
|
156
|
+
return null;
|
|
157
|
+
// f3/rr5: ready.json is the runner's own record of its actual OS start.
|
|
158
|
+
// When both the start marker and ready.json exist, ready.json is
|
|
159
|
+
// authoritative: the marker is the Controller's declared intent (written
|
|
160
|
+
// around the spawn), while ready.json is written by the runner itself
|
|
161
|
+
// before any side effect. A disagreement (PID reuse, spawn retry, stale
|
|
162
|
+
// /proc read) is resolved in favor of ready.json.
|
|
163
|
+
const ready = this.#artifacts.readReadyFile(job.taskId, job.id);
|
|
164
|
+
if (ready !== null) {
|
|
165
|
+
return { pid: ready.pid, startIdentity: ready.startIdentity };
|
|
166
|
+
}
|
|
167
|
+
if (marker.startIdentity !== "pending") {
|
|
168
|
+
// Real start marker with a pid — the runner was spawned.
|
|
169
|
+
return { pid: marker.pid, startIdentity: marker.startIdentity };
|
|
170
|
+
}
|
|
171
|
+
// Pending marker with no ready.json: spawn unconfirmed.
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* f3: Adopt a queued job to running, then harvest exit or handle a dead
|
|
176
|
+
* process. This is the only legal path from queued to a terminal state —
|
|
177
|
+
* complete/unknown require `running`.
|
|
178
|
+
*/
|
|
179
|
+
#adoptAndContinue(job, process, now) {
|
|
180
|
+
this.#store.transitionDurableJob(job.taskId, job.id, (current) => startDurableJob(current, process, now), now);
|
|
181
|
+
// The runner may already have finished while the Controller was down.
|
|
182
|
+
const exit = this.#artifacts.readExitJson(job.taskId, job.id);
|
|
183
|
+
if (exit !== null) {
|
|
184
|
+
this.#harvestExit(job, exit, now);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const identity = this.#process.processStartIdentity(process.pid);
|
|
188
|
+
const alive = identity !== undefined
|
|
189
|
+
&& identity === process.startIdentity
|
|
190
|
+
&& this.#process.isProcessAlive(process.pid);
|
|
191
|
+
if (!alive) {
|
|
192
|
+
this.#handleDeadProcess(job, now);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
#startJob(job, now) {
|
|
196
|
+
const spec = {
|
|
197
|
+
jobId: job.id,
|
|
198
|
+
taskId: job.taskId,
|
|
199
|
+
workspace: job.workspace,
|
|
200
|
+
env: job.env,
|
|
201
|
+
steps: job.steps,
|
|
202
|
+
defaultStepTimeoutMs: DEFAULT_STEP_TIMEOUT_MS,
|
|
203
|
+
artifactDir: this.#artifacts.artifactDir(job.taskId, job.id),
|
|
204
|
+
head: job.head
|
|
205
|
+
};
|
|
206
|
+
const specPath = this.#artifacts.writeSpec(job.taskId, job.id, spec);
|
|
207
|
+
// f3: Write a pending start marker BEFORE spawning. If the Controller dies
|
|
208
|
+
// between this write and the spawn, the next pass sees the pending marker
|
|
209
|
+
// and re-spawns safely (no side effects without a ready file).
|
|
210
|
+
this.#artifacts.writeStartMarker(job.taskId, job.id, {
|
|
211
|
+
pid: 0,
|
|
212
|
+
startIdentity: "pending",
|
|
213
|
+
spawnedAt: now.toISOString()
|
|
214
|
+
});
|
|
215
|
+
const spawned = this.#process.spawnJobRunner(specPath);
|
|
216
|
+
// Best-effort update with the real pid. If this fails (Controller death),
|
|
217
|
+
// the runner's own ready.json is the fallback: the next reconcile reads
|
|
218
|
+
// ready and adopts. The runner checks ready with O_EXCL and exits if it
|
|
219
|
+
// already exists, preventing duplicate execution.
|
|
220
|
+
this.#artifacts.writeStartMarker(job.taskId, job.id, {
|
|
221
|
+
pid: spawned.pid,
|
|
222
|
+
startIdentity: spawned.startIdentity ?? String(spawned.pid),
|
|
223
|
+
spawnedAt: now.toISOString()
|
|
224
|
+
});
|
|
225
|
+
// rr6/f1: Bound the supervision latency without a busy loop. One follow-up
|
|
226
|
+
// pass adopts the queued job to running (the real-pid marker path above),
|
|
227
|
+
// and the runner-exit callback schedules the harvest pass while the
|
|
228
|
+
// Controller lives. Both are idempotent no-ops if the job already
|
|
229
|
+
// terminalized or no longer exists; the recovery interval stays the
|
|
230
|
+
// cross-restart fallback. No new timer is pinned while the Controller is
|
|
231
|
+
// idle — each wake is a single event-driven signal.
|
|
232
|
+
spawned.onExit?.(() => this.#wake(job.taskId));
|
|
233
|
+
this.#wake(job.taskId);
|
|
234
|
+
}
|
|
235
|
+
#superviseRunning(job, now) {
|
|
236
|
+
// 1. Harvest exit.json if present.
|
|
237
|
+
const exit = this.#artifacts.readExitJson(job.taskId, job.id);
|
|
238
|
+
if (exit !== null) {
|
|
239
|
+
this.#harvestExit(job, exit, now);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// 2. Cancel requested: write fence + SIGTERM.
|
|
243
|
+
if (job.cancelRequestedAt !== undefined && job.process !== undefined) {
|
|
244
|
+
this.#artifacts.writeCancelFence(job.taskId, job.id);
|
|
245
|
+
this.#process.signalIfOwned(job.process.pid, job.process.startIdentity, "SIGTERM");
|
|
246
|
+
}
|
|
247
|
+
// 3. Process liveness via pid + startIdentity.
|
|
248
|
+
if (job.process !== undefined) {
|
|
249
|
+
const identity = this.#process.processStartIdentity(job.process.pid);
|
|
250
|
+
const alive = identity !== undefined
|
|
251
|
+
&& identity === job.process.startIdentity
|
|
252
|
+
&& this.#process.isProcessAlive(job.process.pid);
|
|
253
|
+
if (!alive) {
|
|
254
|
+
this.#handleDeadProcess(job, now);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// 4. Heartbeat freshness.
|
|
259
|
+
const heartbeatMtime = this.#artifacts.heartbeatMtime(job.taskId, job.id);
|
|
260
|
+
if (heartbeatMtime !== null) {
|
|
261
|
+
const staleMs = now.getTime() - heartbeatMtime;
|
|
262
|
+
if (staleMs > HEARTBEAT_STALE_MS && job.process !== undefined) {
|
|
263
|
+
this.#process.signalIfOwned(job.process.pid, job.process.startIdentity, "SIGTERM");
|
|
264
|
+
const existing = this.#sigkillAt.get(this.#sigkillKey(job));
|
|
265
|
+
if (existing === undefined) {
|
|
266
|
+
this.#sigkillAt.set(this.#sigkillKey(job), now.getTime() + SIGKILL_GRACE_MS);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const recordHeartbeat = job.heartbeatAt !== undefined
|
|
270
|
+
? Date.parse(job.heartbeatAt)
|
|
271
|
+
: 0;
|
|
272
|
+
if (heartbeatMtime > recordHeartbeat) {
|
|
273
|
+
this.#store.transitionDurableJob(job.taskId, job.id, (current) => touchDurableJobHeartbeat(current, new Date(heartbeatMtime)), now);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 5. SIGKILL escalation.
|
|
277
|
+
const sigkillAt = this.#sigkillAt.get(this.#sigkillKey(job));
|
|
278
|
+
if (sigkillAt !== undefined && now.getTime() >= sigkillAt && job.process !== undefined) {
|
|
279
|
+
this.#process.signalIfOwned(job.process.pid, job.process.startIdentity, "SIGKILL");
|
|
280
|
+
this.#sigkillAt.delete(this.#sigkillKey(job));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
#harvestExit(job, exit, now) {
|
|
284
|
+
const result = {
|
|
285
|
+
outcome: exit.outcome,
|
|
286
|
+
exitCode: exit.exitCode,
|
|
287
|
+
signal: exit.signal,
|
|
288
|
+
...(exit.failedStep === undefined ? {} : { failedStep: exit.failedStep }),
|
|
289
|
+
evidenceSource: "exit-artifact",
|
|
290
|
+
steps: exit.steps
|
|
291
|
+
};
|
|
292
|
+
// rr4/finding-7: The terminal transition and the Leader wakeup must be
|
|
293
|
+
// atomic. Compose complete + wakeupNotified and pass the wakeup param so
|
|
294
|
+
// the adapter enqueues the Leader mailbox entry in the same transaction.
|
|
295
|
+
// A separate #notifyWakeup pass would lose the wakeup if the Controller
|
|
296
|
+
// died between the terminal write and the flag flip.
|
|
297
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, result, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
|
|
298
|
+
this.#deliverTerminalEvent(terminal);
|
|
299
|
+
this.#sigkillAt.delete(this.#sigkillKey(job));
|
|
300
|
+
}
|
|
301
|
+
#handleDeadProcess(job, now) {
|
|
302
|
+
const checkpoint = this.#artifacts.readCheckpoint(job.taskId, job.id);
|
|
303
|
+
const proven = checkpoint === null ? null : proveOutcomeFromCheckpoint(job, checkpoint);
|
|
304
|
+
// rr4/finding-7: Same atomic wakeup composition as #harvestExit — the
|
|
305
|
+
// terminal transition and the Leader wakeup commit together.
|
|
306
|
+
if (proven !== null) {
|
|
307
|
+
// The runner died without exit.json, but its step checkpoint covered
|
|
308
|
+
// every planned step: the outcome is still provable from durable
|
|
309
|
+
// evidence. No false pass — the checkpoint is the runner's own record.
|
|
310
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
|
|
311
|
+
this.#deliverTerminalEvent(terminal);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const completedSteps = checkpoint?.completedSteps ?? [];
|
|
315
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner process exited without writing exit.json", completedSteps, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
|
|
316
|
+
this.#deliverTerminalEvent(terminal);
|
|
317
|
+
}
|
|
318
|
+
this.#sigkillAt.delete(this.#sigkillKey(job));
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* f7/rr5: Deliver a terminal notice to the runtime inbox channel when a
|
|
322
|
+
* Job reaches a terminal state. The notice is best-effort: the terminal
|
|
323
|
+
* transition already committed, so a delivery failure must not fail the
|
|
324
|
+
* reconcile pass. The composition root wires this to the runtime event
|
|
325
|
+
* inbox so the Controller wakes immediately instead of waiting for the
|
|
326
|
+
* next poll.
|
|
327
|
+
*/
|
|
328
|
+
#deliverTerminalEvent(job) {
|
|
329
|
+
if (job === null || !isDurableJobTerminal(job.status))
|
|
330
|
+
return;
|
|
331
|
+
this.#terminalEvents?.deliverTerminalEvent({
|
|
332
|
+
taskId: job.taskId,
|
|
333
|
+
jobId: job.id,
|
|
334
|
+
status: job.status,
|
|
335
|
+
outcome: job.result?.outcome ?? job.status
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Recovery fallback for terminal jobs that reached their terminal state
|
|
340
|
+
* before the atomic wakeup composition existed (or whose wakeup was lost).
|
|
341
|
+
* The normal path (#harvestExit / #handleDeadProcess) already composes
|
|
342
|
+
* complete + wakeupNotified + enqueue in one transaction; this pass only
|
|
343
|
+
* fires for jobs that somehow slipped through without wakeupNotified.
|
|
344
|
+
*/
|
|
345
|
+
#notifyWakeup(job, now) {
|
|
346
|
+
this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(current, now), now, { reason: "job-finished", refs: wakeupRefs(job) });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function wakeupRefs(job) {
|
|
350
|
+
const refs = [{ type: "task", id: job.taskId }];
|
|
351
|
+
if (job.owner.kind === "work-item") {
|
|
352
|
+
refs.push({ type: "work-item", taskId: job.taskId, id: job.owner.workItemId });
|
|
353
|
+
}
|
|
354
|
+
return refs;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Prove a terminal outcome from the step checkpoint when the runner died
|
|
358
|
+
* without writing exit.json. Returns null (fail closed → unknown) unless the
|
|
359
|
+
* checkpoint covers every planned step exactly, by name.
|
|
360
|
+
*/
|
|
361
|
+
function proveOutcomeFromCheckpoint(job, checkpoint) {
|
|
362
|
+
const byName = new Map(checkpoint.completedSteps.map((step) => [step.name, step]));
|
|
363
|
+
if (byName.size !== checkpoint.completedSteps.length)
|
|
364
|
+
return null;
|
|
365
|
+
if (byName.size !== job.steps.length)
|
|
366
|
+
return null;
|
|
367
|
+
const ordered = [];
|
|
368
|
+
for (const planned of job.steps) {
|
|
369
|
+
const step = byName.get(planned.name);
|
|
370
|
+
if (step === undefined)
|
|
371
|
+
return null;
|
|
372
|
+
ordered.push(step);
|
|
373
|
+
}
|
|
374
|
+
const failing = ordered.find((step) => step.timedOut || step.exitCode !== 0 || step.signal !== null);
|
|
375
|
+
if (failing === undefined) {
|
|
376
|
+
return { outcome: "succeeded", exitCode: 0, signal: null, steps: ordered };
|
|
377
|
+
}
|
|
378
|
+
if (failing.timedOut) {
|
|
379
|
+
return {
|
|
380
|
+
outcome: "timed-out",
|
|
381
|
+
exitCode: failing.exitCode,
|
|
382
|
+
signal: failing.signal,
|
|
383
|
+
failedStep: failing.name,
|
|
384
|
+
steps: ordered
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
outcome: "failed",
|
|
389
|
+
exitCode: failing.exitCode,
|
|
390
|
+
signal: failing.signal,
|
|
391
|
+
failedStep: failing.name,
|
|
392
|
+
steps: ordered
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Production ports
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
export function createLinuxProcessPort() {
|
|
399
|
+
return {
|
|
400
|
+
spawnJobRunner(specPath) {
|
|
401
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL("../job/jobRunner.js", import.meta.url)), specPath], { detached: true, stdio: "ignore" });
|
|
402
|
+
child.unref();
|
|
403
|
+
const startIdentity = readLinuxProcessStartIdentity(child.pid);
|
|
404
|
+
return {
|
|
405
|
+
pid: child.pid,
|
|
406
|
+
startIdentity,
|
|
407
|
+
// rr6/f1: Wake the supervisor when the runner exits so the Controller
|
|
408
|
+
// harvests the terminal artifact immediately. The listener does not
|
|
409
|
+
// re-ref the child (unref already detached the event-loop hold), so it
|
|
410
|
+
// cannot pin the Controller; if the Controller dies first, the
|
|
411
|
+
// recovery interval remains the fallback.
|
|
412
|
+
onExit: (callback) => {
|
|
413
|
+
child.on("exit", () => callback());
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
processStartIdentity(pid) {
|
|
418
|
+
return readLinuxProcessStartIdentity(pid);
|
|
419
|
+
},
|
|
420
|
+
isProcessAlive(pid) {
|
|
421
|
+
try {
|
|
422
|
+
process.kill(pid, 0);
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
return error.code !== "ESRCH";
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
signalIfOwned(pid, startIdentity, signal) {
|
|
430
|
+
if (readLinuxProcessStartIdentity(pid) !== startIdentity)
|
|
431
|
+
return;
|
|
432
|
+
try {
|
|
433
|
+
process.kill(pid, signal);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
if (error.code !== "ESRCH")
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
export function createFileArtifactPort(home) {
|
|
443
|
+
return {
|
|
444
|
+
artifactDir(taskId, jobId) {
|
|
445
|
+
return join(home, "artifacts", "jobs", taskId, jobId);
|
|
446
|
+
},
|
|
447
|
+
writeSpec(taskId, jobId, spec) {
|
|
448
|
+
const dir = join(home, "artifacts", "jobs", taskId, jobId);
|
|
449
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
450
|
+
chmodSync(dir, 0o700);
|
|
451
|
+
const specPath = join(dir, "spec.json");
|
|
452
|
+
writeTextFileAtomically(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
453
|
+
return specPath;
|
|
454
|
+
},
|
|
455
|
+
writeCancelFence(taskId, jobId) {
|
|
456
|
+
const dir = join(home, "artifacts", "jobs", taskId, jobId);
|
|
457
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
458
|
+
const fencePath = join(dir, "cancel");
|
|
459
|
+
if (!existsSync(fencePath)) {
|
|
460
|
+
const fd = openSync(fencePath, "w", 0o600);
|
|
461
|
+
closeSync(fd);
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
readExitJson(taskId, jobId) {
|
|
465
|
+
return readJsonFile(join(home, "artifacts", "jobs", taskId, jobId, "exit.json"));
|
|
466
|
+
},
|
|
467
|
+
readCheckpoint(taskId, jobId) {
|
|
468
|
+
return readJsonFile(join(home, "artifacts", "jobs", taskId, jobId, "checkpoint.json"));
|
|
469
|
+
},
|
|
470
|
+
heartbeatMtime(taskId, jobId) {
|
|
471
|
+
try {
|
|
472
|
+
return statSync(join(home, "artifacts", "jobs", taskId, jobId, "heartbeat")).mtimeMs;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
writeStartMarker(taskId, jobId, marker) {
|
|
479
|
+
const dir = join(home, "artifacts", "jobs", taskId, jobId);
|
|
480
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
481
|
+
writeTextFileAtomically(join(dir, "start.json"), `${JSON.stringify(marker, null, 2)}\n`);
|
|
482
|
+
},
|
|
483
|
+
readStartMarker(taskId, jobId) {
|
|
484
|
+
return readJsonFile(join(home, "artifacts", "jobs", taskId, jobId, "start.json"));
|
|
485
|
+
},
|
|
486
|
+
readReadyFile(taskId, jobId) {
|
|
487
|
+
return readJsonFile(join(home, "artifacts", "jobs", taskId, jobId, "ready.json"));
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function readJsonFile(path) {
|
|
492
|
+
try {
|
|
493
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
|
|
2
2
|
import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
3
3
|
import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
|
|
4
|
+
import { YUI_CONTROL_PLANE_DESCRIPTOR, YUI_TASK_RUNTIME_DESCRIPTOR, assertExactTaskRuntimeEnvironment, exactControlPlaneDigest, parseExactControlPlaneDescriptor, refreshReusedTaskRuntimeDescriptorSource } from "../runtime/exactControlPlane.js";
|
|
4
5
|
import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
|
|
5
6
|
/**
|
|
6
7
|
* Resolves turn identity from the current durable in-flight fence. The one
|
|
@@ -21,9 +22,13 @@ export function resolveProviderHookRunFence(environment, adapterId, payloadNativ
|
|
|
21
22
|
const roleName = requireIdentity(environment.YUI_ROLE, "Role name");
|
|
22
23
|
const agentId = requireIdentity(environment.YUI_AGENT_ID, "Agent id");
|
|
23
24
|
const workspace = requireIdentity(environment.YUI_WORKSPACE, "YUI workspace");
|
|
24
|
-
const
|
|
25
|
+
const runtimeSource = environment[YUI_TASK_RUNTIME_DESCRIPTOR];
|
|
26
|
+
const runtime = runtimeSource === undefined
|
|
27
|
+
? undefined
|
|
28
|
+
: assertExactTaskRuntimeEnvironment(runtimeSource, environment, exactControlPlaneDigest(parseExactControlPlaneDescriptor(requireIdentity(environment[YUI_CONTROL_PLANE_DESCRIPTOR], "Exact control-plane descriptor"))), home);
|
|
29
|
+
const launchId = requireIdentity(runtime?.launchId ?? environment.YUI_LAUNCH_ID, "Launch id");
|
|
25
30
|
const nativeSessionId = requireIdentity(payloadNativeSessionId, "Provider session id");
|
|
26
|
-
const expectedNativeSessionId = environment.YUI_NATIVE_SESSION_ID;
|
|
31
|
+
const expectedNativeSessionId = runtime?.nativeSessionId ?? environment.YUI_NATIVE_SESSION_ID;
|
|
27
32
|
if (expectedNativeSessionId !== undefined
|
|
28
33
|
&& nativeSessionId !== requireIdentity(expectedNativeSessionId, "YUI native session id")) {
|
|
29
34
|
throw new Error("Provider lifecycle hook native session does not match its launch envelope.");
|
|
@@ -52,7 +57,7 @@ export function resolveProviderHookRunFence(environment, adapterId, payloadNativ
|
|
|
52
57
|
&& !hasRuntimeCleanupObligation(mailbox);
|
|
53
58
|
const executionRef = mailbox?.processing?.executionRef;
|
|
54
59
|
const startupRunId = options.allowPreallocatedClaudeStartup === true
|
|
55
|
-
? requireIdentity(environment.YUI_RUN_ID, "Run id")
|
|
60
|
+
? requireIdentity(runtime?.runId ?? environment.YUI_RUN_ID, "Run id")
|
|
56
61
|
: undefined;
|
|
57
62
|
const deterministicClaudeStartup = adapterId === "claude"
|
|
58
63
|
&& options.allowPreallocatedClaudeStartup === true
|
|
@@ -77,6 +82,30 @@ export function resolveProviderHookRunFence(environment, adapterId, payloadNativ
|
|
|
77
82
|
throw new Error("Provider lifecycle hook has no matching durable in-flight Run.");
|
|
78
83
|
}
|
|
79
84
|
const runId = inFlight?.runId ?? startupRunId;
|
|
85
|
+
let effectiveRuntime = runtime;
|
|
86
|
+
let effectiveLaunchId = launchId;
|
|
87
|
+
const sessionLaunchId = session?.launchId;
|
|
88
|
+
if (runtime !== undefined
|
|
89
|
+
&& session !== undefined
|
|
90
|
+
&& sessionLaunchId !== undefined
|
|
91
|
+
&& typeof runtimeSource === "string"
|
|
92
|
+
&& !runtimeSource.trimStart().startsWith("{")
|
|
93
|
+
&& (runtime.runId !== runId
|
|
94
|
+
|| runtime.launchId !== sessionLaunchId
|
|
95
|
+
|| runtime.nativeSessionId !== session.nativeSessionId)) {
|
|
96
|
+
// A reused native pane keeps its original descriptor source. Advance only
|
|
97
|
+
// that Hook-owned source to the current durable generation before the
|
|
98
|
+
// volatile fence; the Controller no longer scans history to keep it fresh.
|
|
99
|
+
effectiveRuntime = refreshReusedTaskRuntimeDescriptorSource(runtimeSource, home, store, {
|
|
100
|
+
runId,
|
|
101
|
+
launchId: sessionLaunchId,
|
|
102
|
+
nativeSessionId: session.nativeSessionId
|
|
103
|
+
});
|
|
104
|
+
effectiveLaunchId = effectiveRuntime.launchId;
|
|
105
|
+
}
|
|
106
|
+
if (effectiveRuntime?.runId !== undefined && effectiveRuntime.runId !== runId) {
|
|
107
|
+
throw new Error("Provider lifecycle hook Run does not match its current descriptor.");
|
|
108
|
+
}
|
|
80
109
|
const run = store.getActiveAgentRun(taskId, roleName);
|
|
81
110
|
if (run === null
|
|
82
111
|
|| run.id !== runId
|
|
@@ -90,7 +119,7 @@ export function resolveProviderHookRunFence(environment, adapterId, payloadNativ
|
|
|
90
119
|
}
|
|
91
120
|
if (session !== undefined) {
|
|
92
121
|
if (session.adapterId !== adapterId
|
|
93
|
-
|| session.launchId !==
|
|
122
|
+
|| session.launchId !== effectiveLaunchId
|
|
94
123
|
|| session.nativeSessionId !== nativeSessionId
|
|
95
124
|
|| session.effective.workspace.root !== workspace) {
|
|
96
125
|
throw new Error("Provider lifecycle hook Session does not match its durable generation.");
|
|
@@ -108,7 +137,7 @@ export function resolveProviderHookRunFence(environment, adapterId, payloadNativ
|
|
|
108
137
|
taskId,
|
|
109
138
|
roleName,
|
|
110
139
|
agentId,
|
|
111
|
-
launchId,
|
|
140
|
+
launchId: effectiveLaunchId,
|
|
112
141
|
runId,
|
|
113
142
|
...(inFlight?.receiptId === undefined ? {} : { receiptId: inFlight.receiptId }),
|
|
114
143
|
nativeSessionId,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
3
|
import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
|
|
5
4
|
import { TmuxManager } from "../tmux/tmuxManager.js";
|
|
5
|
+
import { tmuxSocketDirectory, tmuxSocketEnvironment } from "../tmux/tmuxSocketEndpoint.js";
|
|
6
6
|
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
7
7
|
import { CONTROLLER_DOMAIN_PATH, domainIdentityPath, removeEphemeralDomainIdentityIfUnchanged, readEphemeralDomainIdentity, withEphemeralDomainIdentityCleanupEpoch } from "./domainIdentity.js";
|
|
8
8
|
const DEFAULT_TERM_GRACE_MS = 1_000;
|
|
@@ -17,7 +17,7 @@ export async function cleanControllerResource(resource, options = {}) {
|
|
|
17
17
|
const ports = options.ports ?? linuxCleanupPorts(environment);
|
|
18
18
|
const cleanup = async () => {
|
|
19
19
|
if (resource.artifact !== undefined) {
|
|
20
|
-
cleanArtifact(resource, ports);
|
|
20
|
+
cleanArtifact(resource, ports, environment);
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
23
|
if (resource.kind === "agent-session") {
|
|
@@ -88,11 +88,11 @@ function assertEphemeralDomainFence(resource) {
|
|
|
88
88
|
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
-
function cleanArtifact(resource, ports) {
|
|
91
|
+
function cleanArtifact(resource, ports, environment) {
|
|
92
92
|
const artifact = resource.artifact;
|
|
93
93
|
if (artifact === undefined)
|
|
94
94
|
throw new Error(`Artifact is unavailable: ${resource.id}.`);
|
|
95
|
-
assertOwnedArtifactPath(resource);
|
|
95
|
+
assertOwnedArtifactPath(resource, environment);
|
|
96
96
|
const currentFingerprint = ports.artifactFingerprint(artifact.path);
|
|
97
97
|
// A concurrent exact cleanup may have already removed this artifact. The
|
|
98
98
|
// desired state is still satisfied; only a changed live inode is ambiguous.
|
|
@@ -205,7 +205,18 @@ function tmuxManagerForResource(resource, environment) {
|
|
|
205
205
|
if (home === undefined) {
|
|
206
206
|
throw new Error(`Tmux resource home is unavailable: ${resource.id}.`);
|
|
207
207
|
}
|
|
208
|
-
|
|
208
|
+
const executor = new NodeCommandExecutor();
|
|
209
|
+
const commandEnvironment = tmuxSocketEnvironment(environment);
|
|
210
|
+
return new TmuxManager(environment.YUI_TMUX_BIN ?? "tmux", {
|
|
211
|
+
run: (command, args, options) => executor.run(command, args, {
|
|
212
|
+
...options,
|
|
213
|
+
environment: {
|
|
214
|
+
...commandEnvironment,
|
|
215
|
+
...options?.environment,
|
|
216
|
+
TMUX_TMPDIR: commandEnvironment.TMUX_TMPDIR
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}, { yuiHome: home });
|
|
209
220
|
}
|
|
210
221
|
function readProcessStartIdentity(pid) {
|
|
211
222
|
try {
|
|
@@ -232,15 +243,13 @@ function unixSocketIsActive(expectedPath) {
|
|
|
232
243
|
return true;
|
|
233
244
|
}
|
|
234
245
|
}
|
|
235
|
-
function assertOwnedArtifactPath(resource) {
|
|
246
|
+
function assertOwnedArtifactPath(resource, environment) {
|
|
236
247
|
const artifact = resource.artifact;
|
|
237
248
|
if (artifact === undefined)
|
|
238
249
|
throw new Error(`Artifact is unavailable: ${resource.id}.`);
|
|
239
|
-
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
240
250
|
const path = resolve(artifact.path);
|
|
241
251
|
if (artifact.artifactKind === "tmux-socket") {
|
|
242
|
-
|
|
243
|
-
if (dirname(path) !== directory
|
|
252
|
+
if (dirname(path) !== tmuxSocketDirectory(environment)
|
|
244
253
|
|| !/^yui-[a-f0-9]{24}$/u.test(basename(path))) {
|
|
245
254
|
throw new Error(`Artifact path is outside the Yui tmux namespace: ${path}.`);
|
|
246
255
|
}
|