@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,211 @@
1
+ export const DEFAULT_GRACEFUL_GRACE_MS = 3_000;
2
+ export const DEFAULT_FORCED_GRACE_MS = 2_000;
3
+ export const DEFAULT_TERMINATION_POLL_MS = 100;
4
+ /**
5
+ * Stops one Role owner with physical exit proof.
6
+ *
7
+ * Sequence: graceful exact-owner tmux stop -> bounded grace -> forced
8
+ * SIGTERM/SIGKILL of the exact Provider root and its process group -> final
9
+ * /proc re-verification. Only when every owned root is absent (PID gone or
10
+ * start identity changed) does the result become `stop-confirmed`; otherwise
11
+ * the Session stays non-terminal and the caller must not commit `stopped`.
12
+ *
13
+ * PID reuse is handled structurally: liveness always pairs the PID with its
14
+ * recorded start identity, so a recycled PID is treated as absent and never
15
+ * signaled.
16
+ */
17
+ export async function terminateSessionOwners(owner, records, ports, options = {}) {
18
+ const gracefulGraceMs = positiveDuration(options.gracefulGraceMs, DEFAULT_GRACEFUL_GRACE_MS, "gracefulGraceMs");
19
+ const forcedGraceMs = positiveDuration(options.forcedGraceMs, DEFAULT_FORCED_GRACE_MS, "forcedGraceMs");
20
+ const pollMs = positiveDuration(options.pollMs, DEFAULT_TERMINATION_POLL_MS, "pollMs");
21
+ const now = ports.now();
22
+ ports.emit({ stage: "stop-requested", owner, at: now });
23
+ let gracefulOk = true;
24
+ try {
25
+ gracefulOk = await ports.gracefulStop(owner);
26
+ }
27
+ catch (error) {
28
+ gracefulOk = false;
29
+ ports.emit({
30
+ stage: "graceful-stop",
31
+ owner,
32
+ detail: error instanceof Error ? error.message : String(error),
33
+ at: ports.now()
34
+ });
35
+ }
36
+ if (gracefulOk) {
37
+ ports.emit({ stage: "graceful-stop", owner, at: ports.now() });
38
+ }
39
+ if (records.length === 0) {
40
+ // No physical owner record: tmux-level cleanup is the only proof
41
+ // available. Report the gap rather than claiming physical zero.
42
+ return {
43
+ outcome: gracefulOk ? "stop-confirmed" : "stop-blocked",
44
+ owner,
45
+ confirmed: [],
46
+ remaining: [],
47
+ ...(gracefulOk
48
+ ? {}
49
+ : { verificationGap: "no-owner-record: tmux stop unconfirmed" })
50
+ };
51
+ }
52
+ // Bounded graceful grace: poll the exact owned trees for exit.
53
+ if (await waitForTreesAbsent(records, ports, gracefulGraceMs, pollMs)) {
54
+ ports.emit({ stage: "stop-confirmed", owner, at: ports.now() });
55
+ return {
56
+ outcome: "stop-confirmed",
57
+ owner,
58
+ confirmed: records,
59
+ remaining: []
60
+ };
61
+ }
62
+ // Forced escalation against the exact roots only. A root whose identity
63
+ // cannot be verified (verification gap) is never signaled: PID reuse
64
+ // safety requires pairing the PID with its start identity.
65
+ ports.emit({ stage: "forced-stop", owner, at: ports.now() });
66
+ for (const record of records) {
67
+ escalateRecord(record, ports, "SIGTERM");
68
+ }
69
+ await waitForTreesAbsent(records, ports, forcedGraceMs, pollMs);
70
+ for (const record of records) {
71
+ escalateRecord(record, ports, "SIGKILL");
72
+ }
73
+ await waitForTreesAbsent(records, ports, forcedGraceMs, pollMs);
74
+ const remaining = [];
75
+ const confirmed = [];
76
+ let verificationGap;
77
+ for (const record of records) {
78
+ const verdict = treeVerdict(record, ports);
79
+ if (verdict.kind === "absent") {
80
+ confirmed.push(record);
81
+ continue;
82
+ }
83
+ if (verdict.kind === "gap") {
84
+ verificationGap = `/proc unreadable for pid ${record.providerRoot.pid}`;
85
+ remaining.push({ record, detail: verificationGap });
86
+ continue;
87
+ }
88
+ const root = rootVerdict(record, ports);
89
+ remaining.push({
90
+ record,
91
+ detail: root.kind === "live"
92
+ ? `provider root pid ${record.providerRoot.pid} still live`
93
+ : `provider children still live for launch ${record.launchId}`
94
+ });
95
+ }
96
+ const outcome = remaining.length === 0 ? "stop-confirmed" : "stop-blocked";
97
+ ports.emit({
98
+ stage: outcome === "stop-confirmed" ? "stop-confirmed" : "stop-blocked",
99
+ owner,
100
+ ...(outcome === "stop-blocked"
101
+ ? {
102
+ detail: remaining
103
+ .map(({ record, detail }) => `${record.launchId}: ${detail}`)
104
+ .join("; ")
105
+ }
106
+ : {}),
107
+ at: ports.now()
108
+ });
109
+ return {
110
+ outcome,
111
+ owner,
112
+ confirmed,
113
+ remaining,
114
+ ...(verificationGap === undefined ? {} : { verificationGap })
115
+ };
116
+ }
117
+ function rootVerdict(record, ports) {
118
+ const { pid, startIdentity } = record.providerRoot;
119
+ const current = ports.processIdentity(pid);
120
+ if (current === undefined) {
121
+ // /proc unreadable or entry gone. Gone is absent; unreadable is a gap.
122
+ return ports.procEntryExists(pid) ? { kind: "gap" } : { kind: "absent" };
123
+ }
124
+ // A zombie has already exited; only the unreaped task struct remains. It
125
+ // cannot execute or hold resources, so it counts as absent for physical
126
+ // exit proof (the parent reaping the zombie does not change liveness).
127
+ if (current.state === "Z")
128
+ return { kind: "absent" };
129
+ return current.startIdentity === startIdentity
130
+ ? { kind: "live" }
131
+ : { kind: "absent" };
132
+ }
133
+ /**
134
+ * Verdict for the whole owned tree: the Provider root plus any surviving
135
+ * child carrying the exact launch fence. A verification gap counts as
136
+ * present: the grace polls must not short-circuit to `stop-confirmed` while
137
+ * `/proc` is unreadable, because that would claim physical zero without
138
+ * proof. The final verdict turns a persistent gap into `stop-blocked`.
139
+ */
140
+ function treeVerdict(record, ports) {
141
+ const root = rootVerdict(record, ports);
142
+ if (root.kind === "live")
143
+ return { kind: "live" };
144
+ // The root is gone or unreadable: a surviving child carrying the exact
145
+ // launch fence keeps the tree live. Children without the fence are
146
+ // unattributed and never block confirmation on their own.
147
+ const fencedChildren = ports
148
+ .listLaunchFencedProcesses(record.launchId)
149
+ .filter((pid) => pid !== record.providerRoot.pid);
150
+ if (fencedChildren.length > 0)
151
+ return { kind: "live" };
152
+ return root;
153
+ }
154
+ /**
155
+ * Signals the exact root (only when its identity is verified) and, while the
156
+ * root is live, its process group. When the root has already exited but a
157
+ * fenced child survives, each fenced child is signaled individually: the
158
+ * group can no longer be proven ours once its leader is gone.
159
+ */
160
+ function escalateRecord(record, ports, signal) {
161
+ const root = rootVerdict(record, ports);
162
+ if (root.kind === "live") {
163
+ try {
164
+ ports.signalProcess(record.providerRoot.pid, signal);
165
+ }
166
+ catch {
167
+ // The root may have exited between verdict and signal; the final
168
+ // verification is authoritative.
169
+ }
170
+ const { processGroupId } = record.providerRoot;
171
+ if (processGroupId !== undefined) {
172
+ try {
173
+ ports.signalProcessGroup(processGroupId, signal);
174
+ }
175
+ catch {
176
+ // Group may already be gone or the root may not be its leader.
177
+ }
178
+ }
179
+ return;
180
+ }
181
+ if (root.kind !== "absent")
182
+ return;
183
+ for (const pid of ports.listLaunchFencedProcesses(record.launchId)) {
184
+ if (pid === record.providerRoot.pid)
185
+ continue;
186
+ try {
187
+ ports.signalProcess(pid, signal);
188
+ }
189
+ catch {
190
+ // A child may have exited between scan and signal.
191
+ }
192
+ }
193
+ }
194
+ async function waitForTreesAbsent(records, ports, timeoutMs, pollMs) {
195
+ const start = Date.now();
196
+ for (;;) {
197
+ if (records.every((record) => treeVerdict(record, ports).kind === "absent")) {
198
+ return true;
199
+ }
200
+ if (Date.now() - start >= timeoutMs)
201
+ return false;
202
+ await ports.sleep(Math.min(pollMs, Math.max(1, timeoutMs - (Date.now() - start))));
203
+ }
204
+ }
205
+ function positiveDuration(value, fallback, label) {
206
+ const resolved = value ?? fallback;
207
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
208
+ throw new TypeError(`${label} must be a positive integer.`);
209
+ }
210
+ return resolved;
211
+ }
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
5
+ import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
5
6
  export const YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR = "YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR";
6
7
  export const YUI_TASK_RUNTIME_SERVICE_NAMESPACE = "YUI_TASK_RUNTIME_SERVICE_NAMESPACE";
7
8
  const MARKER_FILE = ".yui-task-runtime-owner.json";
@@ -15,12 +16,16 @@ export class FileTaskRuntimeIsolation {
15
16
  #controlPlane;
16
17
  #pathLayout;
17
18
  #inspectResources;
19
+ #resourceRegistrarValue;
18
20
  constructor(options) {
19
21
  this.#runtimeRoot = canonicalPath(options.runtimeRoot, "Task runtime root");
20
22
  this.#controlPlane = normalizeControlBoundary(options.controlPlane);
21
23
  this.#pathLayout = taskRuntimePathLayout(options.pathLayout);
22
24
  this.#inspectResources = options.inspectResources;
23
25
  }
26
+ #resourceRegistrar() {
27
+ return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.#controlPlane.yuiHome);
28
+ }
24
29
  preflight(input) {
25
30
  const descriptor = createTaskRuntimeIsolationDescriptor({
26
31
  ...input,
@@ -57,6 +62,7 @@ export class FileTaskRuntimeIsolation {
57
62
  throw new Error(`Task runtime generation is not exactly owned: ${root}.`);
58
63
  }
59
64
  ensureOwnedDirectories(descriptor);
65
+ this.#resourceRegistrar().registerTaskRuntimeIsolation(descriptor);
60
66
  return;
61
67
  }
62
68
  ensureDirectoryChain(this.#runtimeRoot, dirname(root));
@@ -73,6 +79,7 @@ export class FileTaskRuntimeIsolation {
73
79
  mode: 0o600
74
80
  });
75
81
  ensureOwnedDirectories(descriptor);
82
+ this.#resourceRegistrar().registerTaskRuntimeIsolation(descriptor);
76
83
  }
77
84
  catch (error) {
78
85
  const current = inspectGenerationRoot(descriptor, fingerprint);
@@ -113,6 +120,12 @@ export class FileTaskRuntimeIsolation {
113
120
  throw new Error("Task runtime resources changed during cleanup claim.");
114
121
  }
115
122
  rmSync(claimed, { recursive: true });
123
+ this.#resourceRegistrar().markPathsDeleted([
124
+ descriptor.roots.generation,
125
+ descriptor.roots.data,
126
+ descriptor.roots.cache,
127
+ descriptor.roots.temporary
128
+ ]);
116
129
  }
117
130
  catch (error) {
118
131
  // Preserve a claimed-but-unverified resource. Restore its exact path only
@@ -11,12 +11,14 @@ export class TmuxSessionHost {
11
11
  tmux;
12
12
  #globalHostId;
13
13
  #createBindingId;
14
+ #onHostCreated;
14
15
  #launchTails = new Map();
15
16
  constructor(planner, tmux, options = {}) {
16
17
  this.planner = planner;
17
18
  this.tmux = tmux;
18
19
  this.#globalHostId = requireSafeIdentity(options.globalHostId ?? "operator", "Global tmux host id");
19
20
  this.#createBindingId = options.createBindingId ?? randomUUID;
21
+ this.#onHostCreated = options.onHostCreated;
20
22
  }
21
23
  async start(request, beforeHostStart) {
22
24
  return this.#launch(request, beforeHostStart);
@@ -190,7 +192,18 @@ export class TmuxSessionHost {
190
192
  });
191
193
  // Process creation is last: every local invariant has already passed.
192
194
  const hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, planned.launch);
193
- return createRuntimeBinding({
195
+ if (hostCreated
196
+ && request.owner.scope === "task"
197
+ && planned.launch.env.YUI_JOB_CALLER_KEY !== undefined
198
+ && this.planner.commitTaskCallerKey !== undefined) {
199
+ this.planner.commitTaskCallerKey({
200
+ taskId: request.owner.taskId,
201
+ roleName: request.owner.roleName,
202
+ agentId: request.agentId,
203
+ callerKey: planned.launch.env.YUI_JOB_CALLER_KEY
204
+ });
205
+ }
206
+ const binding = createRuntimeBinding({
194
207
  id: bindingId,
195
208
  launchId: request.launchId,
196
209
  owner: request.owner,
@@ -207,6 +220,13 @@ export class TmuxSessionHost {
207
220
  : {}),
208
221
  ...(nativeSessionId === undefined ? {} : { nativeSessionId })
209
222
  });
223
+ if (hostCreated && this.#onHostCreated !== undefined) {
224
+ const pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
225
+ if (pane !== undefined) {
226
+ this.#onHostCreated({ binding, pane });
227
+ }
228
+ }
229
+ return binding;
210
230
  }
211
231
  }
212
232
  /** Non-blocking receipt-backed tmux push; it never interprets provider output. */
@@ -238,6 +258,19 @@ async function probeRoleStatus(tmux, hostId, roleName) {
238
258
  ? tmux.probeRoleStatus(hostId, roleName)
239
259
  : tmux.probeRoleStatusAsync(hostId, roleName);
240
260
  }
261
+ async function inspectRolePane(tmux, hostId, roleName) {
262
+ try {
263
+ return tmux.inspectRolePaneAsync === undefined
264
+ ? tmux.inspectRolePane?.(hostId, roleName)
265
+ : await tmux.inspectRolePaneAsync(hostId, roleName);
266
+ }
267
+ catch {
268
+ // The pane may have exited between creation and inspection; the owner
269
+ // recorder treats a missing pane as a verification gap, not a launch
270
+ // failure.
271
+ return undefined;
272
+ }
273
+ }
241
274
  async function killRole(tmux, hostId, roleName) {
242
275
  if (tmux.killRoleAsync === undefined) {
243
276
  tmux.killRole(hostId, roleName);
@@ -0,0 +1,155 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isDurableJobTerminal } from "../job/durableJob.js";
3
+ /**
4
+ * Canonical SHA-256 digest over the normalized actionable facts. Pure and
5
+ * deterministic: the same facts always produce the same digest regardless of
6
+ * collection order.
7
+ */
8
+ export function computeActionabilityDigest(input) {
9
+ const facts = [...input.facts]
10
+ .map((fact) => ({ key: fact.key, value: fact.value }))
11
+ .sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
12
+ const canonical = JSON.stringify({
13
+ taskId: input.taskId,
14
+ taskStatus: input.taskStatus,
15
+ facts
16
+ });
17
+ return createHash("sha256").update(canonical).digest("hex");
18
+ }
19
+ const TERMINAL_WORK_ITEM_STATUSES = new Set(["completed", "retired"]);
20
+ const CONSUMED_INTEGRATION_STATUSES = new Set(["committed", "superseded"]);
21
+ /**
22
+ * Fold a Task's durable records into the normalized actionable facts. This
23
+ * function only reads; it never starts a Controller, queues a wake, writes a
24
+ * Message, or mutates any record.
25
+ *
26
+ * Included facts (each changes only when the durable record changes):
27
+ * - Active Runs: the ownership/liveness picture. A Run starting or ending
28
+ * changes the digest so a lost executor is detected.
29
+ * - Non-terminal/failed WorkItems: pending, running, awaiting-acceptance, and
30
+ * failed items still need Leader or Worker action.
31
+ * - Non-completed ReviewRounds: pending/running/failed rounds still need
32
+ * Leader disposition or a Reviewer.
33
+ * - Unresolved IntegrationAttempts: running/validating/blocked/failed attempts
34
+ * the Leader may need to resolve.
35
+ * - Terminal DurableJobs: new terminal results the Leader should observe.
36
+ * - InputRequests: open and answered/cancelled requests.
37
+ * - User directives explicitly marked `wakePolicy=leader`.
38
+ *
39
+ * Excluded: pure revision growth, progress/heartbeat events, repeated scans of
40
+ * an unchanged blocker, read-only status requests, and waits already owned by
41
+ * an active Worker/Reviewer/Job (the active-Run facts above capture ownership).
42
+ */
43
+ export function collectTaskActionability(store, taskId) {
44
+ const task = store.getTask(taskId);
45
+ if (task === null) {
46
+ throw new Error(`Task not found for actionability projection: ${taskId}.`);
47
+ }
48
+ const facts = [];
49
+ for (const run of store.listAgentRuns(taskId).filter((candidate) => candidate.status === "active")) {
50
+ facts.push({
51
+ key: `active-run:${run.id}`,
52
+ value: [
53
+ run.roleName,
54
+ run.workItemId ?? "",
55
+ run.reviewRoundId ?? "",
56
+ run.updatedAt
57
+ ].join("|")
58
+ });
59
+ }
60
+ for (const item of store.listWorkItems?.(taskId) ?? []) {
61
+ if (TERMINAL_WORK_ITEM_STATUSES.has(item.status))
62
+ continue;
63
+ facts.push({
64
+ key: `work-item:${item.id}`,
65
+ value: `${item.status}|${item.updatedAt}`
66
+ });
67
+ }
68
+ for (const round of store.listReviewRounds?.(taskId) ?? []) {
69
+ if (round.status === "completed")
70
+ continue;
71
+ facts.push({
72
+ key: `review:${round.id}`,
73
+ value: `${round.status}|${round.endedAt ?? round.createdAt}`
74
+ });
75
+ }
76
+ for (const attempt of store.listIntegrationAttempts?.(taskId) ?? []) {
77
+ if (CONSUMED_INTEGRATION_STATUSES.has(attempt.status))
78
+ continue;
79
+ facts.push({
80
+ key: `integration:${attempt.id}`,
81
+ value: `${attempt.status}|${attempt.updatedAt}`
82
+ });
83
+ }
84
+ for (const job of store.listDurableJobs?.(taskId) ?? []) {
85
+ if (!isDurableJobTerminal(job.status))
86
+ continue;
87
+ facts.push({
88
+ key: `durable-job:${job.id}`,
89
+ value: `${job.status}|${job.terminalAt ?? job.updatedAt}`
90
+ });
91
+ }
92
+ for (const request of store.listInputRequests?.(taskId) ?? []) {
93
+ facts.push({
94
+ key: `input:${request.id}`,
95
+ value: `${request.status}|${request.updatedAt}`
96
+ });
97
+ }
98
+ for (const message of store.listMessages?.(taskId) ?? []) {
99
+ if (message.wakePolicy !== "leader")
100
+ continue;
101
+ facts.push({
102
+ key: `directive:${message.id}`,
103
+ value: message.createdAt
104
+ });
105
+ }
106
+ return { taskId, taskStatus: task.status, facts };
107
+ }
108
+ /**
109
+ * Derive the Leader Run disposition from the Task execution projection status.
110
+ * This is machine-derived at yield time so the Scheduler does not depend on
111
+ * free-text Leader summaries.
112
+ */
113
+ export function deriveLeaderRunDisposition(projectionStatus, taskStatus) {
114
+ if (taskStatus !== "active")
115
+ return "completed";
116
+ switch (projectionStatus) {
117
+ case "blocked":
118
+ return "blocked";
119
+ case "waiting-on-agents":
120
+ case "waiting-user":
121
+ return "waiting";
122
+ case "completed":
123
+ case "retired":
124
+ case "archived":
125
+ return "completed";
126
+ default:
127
+ return "progress";
128
+ }
129
+ }
130
+ /**
131
+ * Decide whether a `task-orphaned` scan should create a new Leader Run.
132
+ *
133
+ * Suppression applies only when the last Leader Run ended `waiting` or
134
+ * `blocked` and its `observedActionabilityDigest` equals the current digest.
135
+ * A `progress`/`completed` disposition, a missing digest, or a changed digest
136
+ * always wakes. Computation errors fail open.
137
+ */
138
+ export function decideOrphanWake(input) {
139
+ const { currentDigest, lastLeaderRun } = input;
140
+ if (lastLeaderRun === null)
141
+ return { kind: "wake", digest: currentDigest };
142
+ if (lastLeaderRun.status === "active")
143
+ return { kind: "wake", digest: currentDigest };
144
+ const disposition = lastLeaderRun.disposition;
145
+ if (disposition !== "waiting" && disposition !== "blocked") {
146
+ return { kind: "wake", digest: currentDigest };
147
+ }
148
+ const observed = lastLeaderRun.observedActionabilityDigest;
149
+ if (observed === undefined)
150
+ return { kind: "wake", digest: currentDigest };
151
+ if (observed === currentDigest) {
152
+ return { kind: "suppress", digest: currentDigest, observedDigest: observed };
153
+ }
154
+ return { kind: "wake", digest: currentDigest };
155
+ }
@@ -1,7 +1,7 @@
1
1
  import { selectedSchedulerRoles, selectedSchedulerTasks } from "./ports.js";
2
2
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
3
3
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
4
- import { effectiveLaunchSnapshotsCompatible } from "../executor/effectiveLaunch.js";
4
+ import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain } from "../executor/effectiveLaunch.js";
5
5
  import { RuntimeLaunchError } from "../runtime/ports.js";
6
6
  import { isPreInputReadinessSupported } from "../lifecycle/canonicalLifecycleEvent.js";
7
7
  import { preInputReadinessCapability } from "../lifecycle/providerLifecycleMapping.js";
@@ -328,7 +328,7 @@ function requireResumeSession(role, effective, session) {
328
328
  if (session === null || !hasText(session.nativeSessionId)) {
329
329
  throw new Error(`Role resume has no fixed native session: ${role.taskId}/${role.name}.`);
330
330
  }
331
- if (!effectiveLaunchSnapshotsCompatible(session.effective, effective)) {
331
+ if (!effectiveLaunchSnapshotsCompatibleForTaskMain(session.effective, effective, role.managedWorkspace)) {
332
332
  throw new Error(`Role resume effective snapshot drifted: ${role.taskId}/${role.name}.`);
333
333
  }
334
334
  return session.nativeSessionId;
@@ -376,18 +376,27 @@ function validateRoleSession(role, effective, existing, mode, session) {
376
376
  if (session.agentId !== effective.agentId || session.adapterId !== effective.adapterId) {
377
377
  throw new Error(`Ready Role session identity changed: ${role.taskId}/${role.name}.`);
378
378
  }
379
- if (!effectiveLaunchSnapshotsCompatible(session.effective, effective)) {
379
+ const compatible = mode === "resume"
380
+ ? effectiveLaunchSnapshotsCompatibleForTaskMain(session.effective, effective, role.managedWorkspace)
381
+ : effectiveLaunchSnapshotsCompatible(session.effective, effective);
382
+ if (!compatible) {
380
383
  throw new Error(`Ready Role session effective snapshot changed: ${role.taskId}/${role.name}.`);
381
384
  }
382
385
  if (existing?.nativeSessionId !== undefined
383
- && effectiveLaunchSnapshotsCompatible(existing.effective, effective)
386
+ && effectiveLaunchSnapshotsCompatibleForTaskMain(existing.effective, effective, role.managedWorkspace)
384
387
  && session.nativeSessionId !== existing.nativeSessionId) {
385
388
  throw new Error(`Ready Role session changed the fixed native session id: ${role.taskId}/${role.name}.`);
386
389
  }
387
390
  if (mode === "resume" && session.nativeSessionId !== existing?.nativeSessionId) {
388
391
  throw new Error(`Role resume changed the fixed native session id: ${role.taskId}/${role.name}.`);
389
392
  }
390
- return { ...session, status: "running" };
393
+ return {
394
+ ...session,
395
+ status: "running",
396
+ ...(mode === "resume" && existing !== null
397
+ ? { effective: existing.effective }
398
+ : {})
399
+ };
391
400
  }
392
401
  function hasText(value) {
393
402
  return typeof value === "string" && value.trim().length > 0;
@@ -1,10 +1,19 @@
1
1
  import { selectedSchedulerTasks, isSchedulerTaskWorkspaceReady } from "./ports.js";
2
2
  import { queueLeaderWakeup } from "./wakeupQueue.js";
3
3
  import { projectTaskExecution } from "./taskExecutionProjection.js";
4
+ import { collectTaskActionability, computeActionabilityDigest, decideOrphanWake } from "./actionability.js";
4
5
  /**
5
6
  * Repairs an active Task that has no durable owner capable of advancing it.
6
7
  * This is a low-frequency safety net; normal transitions enqueue their own
7
8
  * Role mailbox signal in the same transaction.
9
+ *
10
+ * Issue 05: a `task-orphaned` wake is suppressed when the last Leader Run
11
+ * ended waiting/blocked and its observed actionability digest equals the
12
+ * current digest. Suppression is silent — no Message, Event, or progress
13
+ * record is written — because a periodic scan that changes nothing is Yui
14
+ * engineering behavior with no Task-visible effect. Computation errors fail
15
+ * open: the Task is woken once and the error is recorded so it can never be
16
+ * silently starved.
8
17
  */
9
18
  export function repairOrphanedActiveTasks(store, now, selection) {
10
19
  const repaired = [];
@@ -36,6 +45,13 @@ export function repairOrphanedActiveTasks(store, now, selection) {
36
45
  || hasUnclaimedLeaderWork(store, task.id, leaderMailbox)) {
37
46
  continue;
38
47
  }
48
+ // Issue 05: digest-based admission. Only the "no-executor" orphan path
49
+ // reaches here; every other needs-leader-action state already has a
50
+ // durable owner (candidate, integration, or pending wake) and is exempt.
51
+ if (projection.reason === "no-executor") {
52
+ if (admitOrphanWake(store, task.id) === "suppress")
53
+ continue;
54
+ }
39
55
  const taskWorkspace = store.getTaskWorkspace(task.id);
40
56
  if (!isSchedulerTaskWorkspaceReady(task, taskWorkspace)) {
41
57
  store.queueTaskProgress(task.id, "task-orphaned", now);
@@ -55,6 +71,50 @@ export function repairOrphanedActiveTasks(store, now, selection) {
55
71
  }
56
72
  return repaired;
57
73
  }
74
+ /**
75
+ * Decide whether one `task-orphaned` scan should wake the Leader. Returns
76
+ * `"suppress"` without writing anything when the digest is unchanged since
77
+ * the last waiting/blocked Leader Run. Computation errors fail open.
78
+ */
79
+ function admitOrphanWake(store, taskId) {
80
+ let digest;
81
+ try {
82
+ const input = collectTaskActionability(store, taskId);
83
+ digest = computeActionabilityDigest(input);
84
+ }
85
+ catch (error) {
86
+ // Fail open: wake once and record the computation error so the Task can
87
+ // never be silently starved by a broken projection.
88
+ store.queueTaskProgress(taskId, "actionability-unknown", new Date());
89
+ return "wake";
90
+ }
91
+ const lastLeaderRun = findLastLeaderRun(store, taskId);
92
+ const decision = decideOrphanWake({ currentDigest: digest, lastLeaderRun });
93
+ return decision.kind === "suppress" ? "suppress" : "wake";
94
+ }
95
+ /**
96
+ * The most recent Leader Run (terminal or active). Active Runs are returned
97
+ * so the admission check never suppresses while a Leader is still running.
98
+ */
99
+ function findLastLeaderRun(store, taskId) {
100
+ const runs = store.listAgentRuns?.(taskId) ?? [];
101
+ let latest = null;
102
+ for (const run of runs) {
103
+ if (run.roleName !== "leader")
104
+ continue;
105
+ if (latest === null || run.createdAt > latest.createdAt)
106
+ latest = run;
107
+ }
108
+ if (latest === null)
109
+ return null;
110
+ return {
111
+ status: latest.status,
112
+ ...(latest.disposition === undefined ? {} : { disposition: latest.disposition }),
113
+ ...(latest.observedActionabilityDigest === undefined
114
+ ? {}
115
+ : { observedActionabilityDigest: latest.observedActionabilityDigest })
116
+ };
117
+ }
58
118
  function hasUnclaimedLeaderWork(store, taskId, leaderMailbox) {
59
119
  if (leaderMailbox === null)
60
120
  return store.getPendingWakeup(taskId) !== null;