@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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. 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 launchId = requireIdentity(environment.YUI_LAUNCH_ID, "Launch id");
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 !== 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
- return new TmuxManager(environment.YUI_TMUX_BIN ?? "tmux", new NodeCommandExecutor(), { yuiHome: home });
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
- const directory = join(tmpdir(), `tmux-${uid}`);
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
  }