@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,415 @@
1
+ import { dataError, taskNotFound, usageError } from "../errors/cliError.js";
2
+ import { createTaskEvent } from "../event/taskEvent.js";
3
+ import { defaultTableWidth, renderTable } from "../output/table.js";
4
+ import { formatTimestamp } from "../output/timePresentation.js";
5
+ import { createReleaseWorkflow, workflowStatus } from "../release/releaseWorkflow.js";
6
+ import { runReleaseWorkflow } from "../release/releaseWorkflowEngine.js";
7
+ import { openConfiguredTaskStore } from "../storage/sqliteStore.js";
8
+ const STEP_KINDS = new Set([
9
+ "pr-create-or-reuse", "ci-confirm", "merge", "version-tag",
10
+ "npm-publish", "fresh-install-smoke", "cli-update",
11
+ "controller-replace", "project-migrate", "post-verify"
12
+ ]);
13
+ const IRREVERSIBILITY_LEVELS = new Set([
14
+ "none", "reversible", "irreversible"
15
+ ]);
16
+ export function runWorkflowCommand(args, store, options) {
17
+ const [command, ...rest] = args;
18
+ switch (command) {
19
+ case "create": return createWorkflow(rest, store, options);
20
+ case "show": return showWorkflow(rest, store);
21
+ case "list": return listWorkflows(rest, store);
22
+ case "status": return workflowStatusCommand(rest, store);
23
+ case "run":
24
+ case "resume":
25
+ throw usageError(`Task workflow ${command} is asynchronous; use the yui CLI or runWorkflowCommandAsync.`);
26
+ default:
27
+ throw usageError(command === undefined
28
+ ? "Task workflow command is required."
29
+ : `Unknown command: task workflow ${command}`);
30
+ }
31
+ }
32
+ export async function runWorkflowCommandAsync(args, store, options) {
33
+ const [command, ...rest] = args;
34
+ if (command !== "run" && command !== "resume") {
35
+ throw usageError(`Task workflow async command must be run or resume: ${String(command)}`);
36
+ }
37
+ const usage = `Task workflow ${command} usage: yui task workflow ${command} <task> <workflow-id> [--grant <grant-id>] [--max-steps <int>].`;
38
+ const parsed = parseTail(rest, new Set(["--grant", "--max-steps"]), usage);
39
+ exactPositionals(parsed.positionals, 2, usage);
40
+ const taskId = parsed.positionals[0];
41
+ requireTask(store, taskId);
42
+ const workflowId = parsed.positionals[1];
43
+ const existing = store.getReleaseWorkflow(taskId, workflowId);
44
+ if (existing === null) {
45
+ throw dataError(`Release workflow not found: ${taskId}/${workflowId}.`);
46
+ }
47
+ const ports = options.ports;
48
+ if (ports === undefined) {
49
+ throw usageError(`Task workflow ${command} requires ports; the yui CLI wires the real adapter.`, usage);
50
+ }
51
+ const grantId = optionalOption(parsed.options, "--grant");
52
+ let maxSteps = options.maxSteps;
53
+ if (parsed.options.has("--max-steps")) {
54
+ const value = Number.parseInt(parsed.options.get("--max-steps"), 10);
55
+ if (!Number.isSafeInteger(value) || value <= 0) {
56
+ throw usageError("--max-steps must be a positive integer.", usage);
57
+ }
58
+ maxSteps = value;
59
+ }
60
+ // The immutable run-intent event commits BEFORE the engine persists the
61
+ // first transition or calls an external system. A run that is killed,
62
+ // crashes, or whose engine rethrows after this point can never leave
63
+ // mutation or effect evidence without an audit record saying the run was
64
+ // attempted. FileTaskStore transactions are synchronous, so the async
65
+ // engine run cannot join this transaction; committing the intent first is
66
+ // what makes the trail crash-safe across the whole run.
67
+ const intentNow = clock(options);
68
+ store.transaction((tx) => {
69
+ recordTaskEvent(tx, taskId, "release-workflow.run-started", {
70
+ workflowId,
71
+ command,
72
+ grantId: grantId ?? existing.grantId
73
+ }, intentNow);
74
+ });
75
+ const result = await runReleaseWorkflow(store, taskId, workflowId, ports, {
76
+ ...(options.now === undefined ? {} : { now: options.now }),
77
+ ...(maxSteps === undefined ? {} : { maxSteps }),
78
+ ...(grantId === undefined ? {} : { grantId })
79
+ });
80
+ // The engine persisted every step transition for crash recovery. The
81
+ // outcome event and the workflow's final state commit together so the
82
+ // audit trail cannot lag the record on a normal engine return.
83
+ const eventNow = clock(options);
84
+ const eventPayload = {
85
+ workflowId,
86
+ grantId: result.workflow.grantId,
87
+ outcome: result.outcome,
88
+ ...(result.stopReason === undefined ? {} : { stopReason: result.stopReason }),
89
+ attempted: result.attempted.join(",")
90
+ };
91
+ try {
92
+ store.transaction((tx) => {
93
+ tx.saveReleaseWorkflow(taskId, result.workflow);
94
+ recordTaskEvent(tx, taskId, "release-workflow.run", eventPayload, eventNow);
95
+ });
96
+ }
97
+ catch (error) {
98
+ // The instance-level event save failed after a normal engine return. The
99
+ // engine already committed the final workflow state for crash recovery
100
+ // and the run-intent event above is durable, so the trail cannot be
101
+ // empty; fall back to a fresh store of the SAME backend so the outcome
102
+ // record commits to the authoritative store, then surface the original
103
+ // failure so the caller knows this path was compromised.
104
+ if (typeof store.rootDirectory === "function") {
105
+ const auditStore = openConfiguredTaskStore(store.rootDirectory());
106
+ recordTaskEvent(auditStore, taskId, "release-workflow.run", eventPayload, eventNow);
107
+ }
108
+ throw error;
109
+ }
110
+ return output(renderRun(result), result);
111
+ }
112
+ function workflowStatusCommand(args, store) {
113
+ const usage = "Task workflow status usage: yui task workflow status <task> <workflow-id>.";
114
+ const parsed = parseTail(args, new Set(), usage);
115
+ exactPositionals(parsed.positionals, 2, usage);
116
+ const taskId = parsed.positionals[0];
117
+ requireTask(store, taskId);
118
+ const workflow = store.getReleaseWorkflow(taskId, parsed.positionals[1]);
119
+ if (workflow === null) {
120
+ throw dataError(`Release workflow not found: ${taskId}/${parsed.positionals[1]}.`);
121
+ }
122
+ const lines = [`Workflow: ${workflow.id}`, `Status: ${workflowStatus(workflow)}`];
123
+ for (const entry of workflow.plan) {
124
+ const step = workflow.steps[entry.id];
125
+ const external = step.externalId === undefined ? "-" : step.externalId;
126
+ lines.push(` [${step.status}] ${entry.id} (${entry.kind}) attempts=${step.attempts} ext=${external}`);
127
+ }
128
+ return output(lines.join("\n").concat("\n"), workflow);
129
+ }
130
+ function renderRun(result) {
131
+ const lines = [
132
+ `Workflow ${result.workflow.id}: ${result.outcome} (status ${result.status})`,
133
+ ...(result.stopReason === undefined ? [] : [`Stop: ${result.stopReason}`]),
134
+ `Attempted: ${result.attempted.length === 0 ? "-" : result.attempted.join(", ")}`
135
+ ];
136
+ return lines.join("\n").concat("\n");
137
+ }
138
+ function createWorkflow(args, store, options) {
139
+ const usage = "Task workflow create usage: yui task workflow create <task> --grant <grant-id> --source-repo <owner/name> --source-commit <sha> [--source-artifact <name@integrity>] --step <id>:<kind> (repeatable) [--step-irreversibility <id>=<level> (repeatable)] [--step-param <id>:<key>=<value> (repeatable)].";
140
+ const parsed = parseMultiValueTail(args, new Set(["--grant", "--source-repo", "--source-commit", "--source-artifact"]), new Set(["--step", "--step-irreversibility", "--step-param"]), usage);
141
+ exactPositionals(parsed.positionals, 1, usage);
142
+ const taskId = parsed.positionals[0];
143
+ requireTask(store, taskId);
144
+ const grantId = requiredOption(parsed.options, "--grant");
145
+ const grant = store.getCapabilityGrant(taskId, grantId);
146
+ if (grant === null) {
147
+ throw dataError(`Capability grant not found: ${taskId}/${grantId}.`);
148
+ }
149
+ const source = buildSource(parsed, usage);
150
+ const plan = buildPlan(parsed, usage);
151
+ const now = clock(options);
152
+ // The workflow record and its audit event commit in one transaction: a
153
+ // failed event save cannot leave a persisted workflow without its audit
154
+ // trail.
155
+ const workflow = store.transaction((tx) => {
156
+ const created = createReleaseWorkflow(tx.nextReleaseWorkflowId(taskId), taskId, { grantId, source, plan }, now);
157
+ tx.saveReleaseWorkflow(taskId, created);
158
+ recordTaskEvent(tx, taskId, "release-workflow.created", {
159
+ workflowId: created.id,
160
+ grantId: created.grantId,
161
+ commit: created.source.commit,
162
+ steps: created.plan.map((entry) => entry.id).join(",")
163
+ }, now);
164
+ return created;
165
+ });
166
+ return output(`Created release workflow ${workflow.id} with ${workflow.plan.length} steps.\n`, workflow);
167
+ }
168
+ function showWorkflow(args, store) {
169
+ const usage = "Task workflow show usage: yui task workflow show <task> <workflow-id>.";
170
+ const parsed = parseTail(args, new Set(), usage);
171
+ exactPositionals(parsed.positionals, 2, usage);
172
+ const taskId = parsed.positionals[0];
173
+ requireTask(store, taskId);
174
+ const workflow = store.getReleaseWorkflow(taskId, parsed.positionals[1]);
175
+ if (workflow === null) {
176
+ throw dataError(`Release workflow not found: ${taskId}/${parsed.positionals[1]}.`);
177
+ }
178
+ return output(renderWorkflow(workflow, store.getConfig().timeZone), workflow);
179
+ }
180
+ function listWorkflows(args, store) {
181
+ const usage = "Task workflow list usage: yui task workflow list <task>.";
182
+ const parsed = parseTail(args, new Set(), usage);
183
+ exactPositionals(parsed.positionals, 1, usage);
184
+ const taskId = parsed.positionals[0];
185
+ requireTask(store, taskId);
186
+ const workflows = store.listReleaseWorkflows(taskId);
187
+ if (workflows.length === 0) {
188
+ return output("No release workflows found.\n", []);
189
+ }
190
+ const rendered = `${renderTable(`Release workflows: ${taskId}`, [
191
+ { header: "ID", minWidth: 6, maxWidth: 22 },
192
+ { header: "Grant", minWidth: 6, maxWidth: 22 },
193
+ { header: "Source", minWidth: 10, maxWidth: 40 },
194
+ { header: "Steps", minWidth: 5, maxWidth: 8 },
195
+ { header: "Status", minWidth: 7, maxWidth: 12 }
196
+ ], workflows.map((workflow) => [
197
+ workflow.id,
198
+ workflow.grantId,
199
+ sourceSummary(workflow),
200
+ `${workflow.plan.length}`,
201
+ workflowStatus(workflow)
202
+ ]), defaultTableWidth())}\n`;
203
+ return output(rendered, workflows);
204
+ }
205
+ function buildSource(parsed, usage) {
206
+ const repo = requiredOption(parsed.options, "--source-repo");
207
+ const separator = repo.indexOf("/");
208
+ if (separator <= 0 || separator === repo.length - 1) {
209
+ throw usageError("--source-repo must use owner/name.", usage);
210
+ }
211
+ const commit = requiredOption(parsed.options, "--source-commit");
212
+ const artifactValue = optionalOption(parsed.options, "--source-artifact");
213
+ if (artifactValue === undefined) {
214
+ return {
215
+ repository: {
216
+ owner: repo.slice(0, separator).trim(),
217
+ name: repo.slice(separator + 1).trim()
218
+ },
219
+ commit
220
+ };
221
+ }
222
+ const at = artifactValue.indexOf("@");
223
+ if (at <= 0 || at === artifactValue.length - 1) {
224
+ throw usageError("--source-artifact must use name@integrity.", usage);
225
+ }
226
+ return {
227
+ repository: {
228
+ owner: repo.slice(0, separator).trim(),
229
+ name: repo.slice(separator + 1).trim()
230
+ },
231
+ commit,
232
+ artifact: {
233
+ name: artifactValue.slice(0, at).trim(),
234
+ integrity: artifactValue.slice(at + 1).trim()
235
+ }
236
+ };
237
+ }
238
+ function buildPlan(parsed, usage) {
239
+ const stepValues = parsed.multiOptions.get("--step") ?? [];
240
+ if (stepValues.length === 0) {
241
+ throw usageError("--step is required.", usage);
242
+ }
243
+ const plan = stepValues.map((value) => {
244
+ const separator = value.indexOf(":");
245
+ if (separator <= 0 || separator === value.length - 1) {
246
+ throw usageError("--step must use id:kind.", usage);
247
+ }
248
+ const id = value.slice(0, separator).trim();
249
+ const kind = value.slice(separator + 1).trim();
250
+ if (!STEP_KINDS.has(kind)) {
251
+ throw usageError(`--step kind is invalid: ${kind}.`, usage);
252
+ }
253
+ return { id, kind: kind };
254
+ });
255
+ const irreversibility = parsed.multiOptions.get("--step-irreversibility") ?? [];
256
+ for (const value of irreversibility) {
257
+ const separator = value.indexOf("=");
258
+ if (separator <= 0 || separator === value.length - 1) {
259
+ throw usageError("--step-irreversibility must use id=level.", usage);
260
+ }
261
+ const id = value.slice(0, separator).trim();
262
+ const level = value.slice(separator + 1).trim();
263
+ if (!IRREVERSIBILITY_LEVELS.has(level)) {
264
+ throw usageError("--step-irreversibility must be one of: none, reversible, irreversible.", usage);
265
+ }
266
+ const entry = plan.find((candidate) => candidate.id === id);
267
+ if (entry === undefined) {
268
+ throw usageError(`--step-irreversibility names an unknown step: ${id}.`, usage);
269
+ }
270
+ entry.irreversibility = level;
271
+ }
272
+ const stepParams = parsed.multiOptions.get("--step-param") ?? [];
273
+ for (const value of stepParams) {
274
+ const separator = value.indexOf(":");
275
+ if (separator <= 0 || separator === value.length - 1) {
276
+ throw usageError("--step-param must use id:key=value.", usage);
277
+ }
278
+ const id = value.slice(0, separator).trim();
279
+ const assignment = value.slice(separator + 1);
280
+ const equals = assignment.indexOf("=");
281
+ if (equals <= 0 || equals === assignment.length - 1) {
282
+ throw usageError("--step-param must use id:key=value.", usage);
283
+ }
284
+ const entry = plan.find((candidate) => candidate.id === id);
285
+ if (entry === undefined) {
286
+ throw usageError(`--step-param names an unknown step: ${id}.`, usage);
287
+ }
288
+ const key = assignment.slice(0, equals).trim();
289
+ const paramValue = assignment.slice(equals + 1).trim();
290
+ if (key.length === 0 || paramValue.length === 0) {
291
+ throw usageError("--step-param must use id:key=value.", usage);
292
+ }
293
+ entry.params = { ...(entry.params ?? {}), [key]: paramValue };
294
+ }
295
+ return plan;
296
+ }
297
+ function sourceSummary(workflow) {
298
+ const { owner, name } = workflow.source.repository;
299
+ const shortCommit = workflow.source.commit.slice(0, 7);
300
+ return `${owner}/${name}@${shortCommit}`;
301
+ }
302
+ function renderWorkflow(workflow, timeZone) {
303
+ const lines = [
304
+ `Workflow: ${workflow.id}`,
305
+ `Task: ${workflow.taskId}`,
306
+ `Grant: ${workflow.grantId}`,
307
+ `Source: ${sourceSummary(workflow)}`,
308
+ `Status: ${workflowStatus(workflow)}`,
309
+ "Steps:"
310
+ ];
311
+ for (const entry of workflow.plan) {
312
+ const step = workflow.steps[entry.id];
313
+ const external = step.externalId === undefined ? "-" : step.externalId;
314
+ lines.push(` [${step.status}] ${entry.id} (${entry.kind}) attempts=${step.attempts} ext=${external}`);
315
+ }
316
+ lines.push(`Created: ${formatTimestamp(workflow.createdAt, timeZone)}`);
317
+ lines.push(`Updated: ${formatTimestamp(workflow.updatedAt, timeZone)}`);
318
+ return lines.join("\n").concat("\n");
319
+ }
320
+ function requireTask(store, taskId) {
321
+ const id = requiredText(taskId, "Task id");
322
+ const task = store.getTask(id);
323
+ if (task === null)
324
+ throw taskNotFound(id);
325
+ return task;
326
+ }
327
+ function recordTaskEvent(store, taskId, type, payload, now) {
328
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, type, payload, now));
329
+ }
330
+ function requiredOption(options, name) {
331
+ return requiredText(options.get(name), name);
332
+ }
333
+ function optionalOption(options, name) {
334
+ if (!options.has(name))
335
+ return undefined;
336
+ return requiredText(options.get(name), name);
337
+ }
338
+ function requiredText(value, label) {
339
+ const normalized = value?.trim();
340
+ if (normalized === undefined || normalized.length === 0) {
341
+ throw usageError(`${label} is required.`);
342
+ }
343
+ return normalized;
344
+ }
345
+ function clock(options) {
346
+ return options.now?.() ?? new Date();
347
+ }
348
+ function output(value, data) {
349
+ return data === undefined
350
+ ? { kind: "output", output: value }
351
+ : { kind: "output", output: value, data };
352
+ }
353
+ function exactPositionals(values, count, usage) {
354
+ if (values.length !== count || values.some((value) => value.trim().length === 0)) {
355
+ throw usageError(usage);
356
+ }
357
+ }
358
+ function parseTail(args, valueOptions, usage) {
359
+ const positionals = [];
360
+ const options = new Map();
361
+ for (let index = 0; index < args.length; index += 1) {
362
+ const value = args[index];
363
+ if (!value.startsWith("--")) {
364
+ positionals.push(value);
365
+ continue;
366
+ }
367
+ if (!valueOptions.has(value)) {
368
+ throw usageError(`Unsupported option: ${value}.`, usage);
369
+ }
370
+ if (options.has(value)) {
371
+ throw usageError(`Option may only be specified once: ${value}.`, usage);
372
+ }
373
+ const optionValue = args[index + 1];
374
+ if (optionValue === undefined || optionValue.startsWith("--")) {
375
+ throw usageError(`${value} is required.`, usage);
376
+ }
377
+ options.set(value, optionValue);
378
+ index += 1;
379
+ }
380
+ return { positionals, options };
381
+ }
382
+ function parseMultiValueTail(args, valueOptions, repeatOptions, usage) {
383
+ const positionals = [];
384
+ const options = new Map();
385
+ const multiOptions = new Map();
386
+ for (let index = 0; index < args.length; index += 1) {
387
+ const value = args[index];
388
+ if (!value.startsWith("--")) {
389
+ positionals.push(value);
390
+ continue;
391
+ }
392
+ if (!valueOptions.has(value) && !repeatOptions.has(value)) {
393
+ throw usageError(`Unsupported option: ${value}.`, usage);
394
+ }
395
+ if (repeatOptions.has(value)) {
396
+ const optionValue = args[index + 1];
397
+ if (optionValue === undefined || optionValue.startsWith("--")) {
398
+ throw usageError(`${value} is required.`, usage);
399
+ }
400
+ multiOptions.set(value, [...(multiOptions.get(value) ?? []), optionValue]);
401
+ index += 1;
402
+ continue;
403
+ }
404
+ if (options.has(value)) {
405
+ throw usageError(`Option may only be specified once: ${value}.`, usage);
406
+ }
407
+ const optionValue = args[index + 1];
408
+ if (optionValue === undefined || optionValue.startsWith("--")) {
409
+ throw usageError(`${value} is required.`, usage);
410
+ }
411
+ options.set(value, optionValue);
412
+ index += 1;
413
+ }
414
+ return { positionals, options, multiOptions };
415
+ }
@@ -1,6 +1,31 @@
1
1
  export const DEFAULT_RECONCILIATION_INTERVAL_SECONDS = 120;
2
2
  export const MIN_RECONCILIATION_INTERVAL_SECONDS = 5;
3
3
  export const MAX_RECONCILIATION_INTERVAL_SECONDS = 300;
4
+ export const DEFAULT_RESOURCES_GC_MODE = "report";
5
+ /**
6
+ * Resolves the Resource GC mode. `report` (default) only reports candidates;
7
+ * `quarantine` allows `yui resources gc --apply` to quarantine releasable
8
+ * resources.
9
+ */
10
+ export function resolveResourcesGcMode(value) {
11
+ if (value === undefined)
12
+ return DEFAULT_RESOURCES_GC_MODE;
13
+ if (value === "report" || value === "quarantine")
14
+ return value;
15
+ throw new TypeError("resourcesGcMode must be 'report' or 'quarantine'.");
16
+ }
17
+ /**
18
+ * Whether the Controller may automatically quarantine resources for terminal
19
+ * Tasks. Defaults to false: automatic GC is always opt-in, and permanent
20
+ * deletion remains a manual, delayed step.
21
+ */
22
+ export function resolveResourcesGcAutoQuarantine(value) {
23
+ if (value === undefined)
24
+ return false;
25
+ if (typeof value === "boolean")
26
+ return value;
27
+ throw new TypeError("resourcesGcAutoQuarantine must be a boolean.");
28
+ }
4
29
  /**
5
30
  * Resolves the durable Yui setting used for low-frequency recovery
6
31
  * reconciliation. Normal durable state changes wake the Controller through
@@ -16,3 +41,40 @@ export function reconciliationIntervalMilliseconds(value) {
16
41
  }
17
42
  return seconds * 1_000;
18
43
  }
44
+ /**
45
+ * Issue 07 (Leader convergence) feature mode. `display` only shows the
46
+ * read-only next-action decision support; `warn` additionally reports
47
+ * duplicate deliveries and semantic-budget warnings; `enforce` hard-blocks
48
+ * only exact duplicates, while semantic-budget exhaustion remains a warning
49
+ * because it must not override Leader judgment. The mode is additive and
50
+ * optional — Homes without it keep the `display` default, so no config
51
+ * migration is required.
52
+ */
53
+ export const LEADER_NEXT_ACTION_MODES = ["display", "warn", "enforce"];
54
+ export const DEFAULT_LEADER_NEXT_ACTION_MODE = "display";
55
+ export function resolveLeaderNextActionMode(value) {
56
+ if (value === undefined || value === null)
57
+ return DEFAULT_LEADER_NEXT_ACTION_MODE;
58
+ if (typeof value !== "string") {
59
+ throw new TypeError("leaderNextActionMode must be display, warn, or enforce.");
60
+ }
61
+ const normalized = value.trim().toLowerCase();
62
+ if (normalized.length === 0)
63
+ return DEFAULT_LEADER_NEXT_ACTION_MODE;
64
+ if (!LEADER_NEXT_ACTION_MODES.includes(normalized)) {
65
+ throw new TypeError("leaderNextActionMode must be display, warn, or enforce.");
66
+ }
67
+ return normalized;
68
+ }
69
+ /**
70
+ * Resolve the effective mode. An explicit environment override
71
+ * (`YUI_LEADER_NEXT_ACTION_MODE`) wins over the durable config value so a
72
+ * single CLI invocation can be tightened or loosened without a config write.
73
+ */
74
+ export function leaderNextActionMode(configured, env = process.env) {
75
+ const override = env.YUI_LEADER_NEXT_ACTION_MODE;
76
+ if (override !== undefined && override.trim().length > 0) {
77
+ return resolveLeaderNextActionMode(override);
78
+ }
79
+ return resolveLeaderNextActionMode(configured);
80
+ }
@@ -8,6 +8,8 @@ import { hasRuntimeLifecycleWork } from "../runtime/lifecycleReservation.js";
8
8
  import { assertControllerStatusIdentity } from "../runtime/exactControlPlane.js";
9
9
  import { EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES } from "./domainIdentity.js";
10
10
  import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
11
+ import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
12
+ import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePreparer.js";
11
13
  const STARTUP_TIMEOUT_MS = 5_000;
12
14
  // A lifecycle RPC may legitimately occupy the Controller for 30 seconds.
13
15
  // Restart must allow that request to drain before deciding shutdown is stuck.
@@ -19,6 +21,15 @@ const CONFIGURATION_REFRESH_TIMEOUT_MS = 500;
19
21
  const CONTROLLER_OPERATIONAL_ENVIRONMENT = [
20
22
  ...AGENT_OPERATIONAL_ENVIRONMENT_NAMES,
21
23
  "YUI_TMUX_BIN",
24
+ // Issue 01: YUI_STORE_BACKEND/YUI_STORE_WORKER are reserved for tests and
25
+ // explicit recovery commands. A Controller spawned by such a command must
26
+ // inherit the forced backend; otherwise a layout-7 test Home silently opens
27
+ // SQLite, creates yui.db, and diverges from the file store the test writes.
28
+ "YUI_STORE_BACKEND",
29
+ "YUI_STORE_WORKER",
30
+ // rr13/test: Forward the liveness seam so an integration test's Controller
31
+ // subprocess does not reap a saved active Leader Run without a real tmux role.
32
+ "YUI_TEST_ROLE_LIVENESS_PRESENT",
22
33
  ...EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES
23
34
  ];
24
35
  /**
@@ -255,9 +266,14 @@ function controllerSpawnEnvironment(home, source) {
255
266
  // The Controller remains authoritative for reporting an invalid/unavailable
256
267
  // home. Environment filtering must never fall back to forwarding all names.
257
268
  }
269
+ // Merge with process.env so a partial source (e.g. a managed-Session env
270
+ // that only carries YUI_SESSION_SCOPE/YUI_TASK_ID) still forwards
271
+ // operational vars like YUI_STORE_BACKEND and PATH. The source wins on
272
+ // conflict; the whitelist still gates every forwarded name.
273
+ const effectiveSource = { ...process.env, ...source };
258
274
  const environment = {};
259
275
  for (const name of allowed) {
260
- const value = source[name];
276
+ const value = effectiveSource[name];
261
277
  if (value !== undefined)
262
278
  environment[name] = value;
263
279
  }
@@ -439,6 +455,31 @@ export class FileTaskWorkflowRuntime {
439
455
  }
440
456
  }
441
457
  }
458
+ /**
459
+ * Issue 03 archive postcondition. Re-verifies physical absence after the
460
+ * runtime stop and blocks archive while any owned Provider root is still
461
+ * live, preserving the owner records for Operator recovery.
462
+ */
463
+ async assertTaskPhysicalResourcesReleased(taskId) {
464
+ const reconciliation = new SessionOwnerReconciliation({
465
+ home: this.home,
466
+ store: this.store,
467
+ environment: this.clientOptions.environment,
468
+ tmux: this.tmux
469
+ });
470
+ const records = this.store.listSessionOwners().filter((record) => (record.owner.scope === "task" && record.owner.taskId === taskId));
471
+ if (records.length === 0)
472
+ return;
473
+ const report = reconciliation.report();
474
+ const blockers = report.entries.filter((entry) => (entry.owner.scope === "task"
475
+ && entry.owner.taskId === taskId
476
+ && entry.archiveBlocked));
477
+ if (blockers.length === 0)
478
+ return;
479
+ throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task archive blocked: ${blockers.length} owned physical resource(s) still live: `
480
+ + blockers.map((entry) => (`${entry.owner.roleName}/${entry.launchId}`
481
+ + ` (pid ${entry.physical?.alive === true ? entry.physical.pid : "?"})`)).join("; "));
482
+ }
442
483
  async stopGlobalRoleSession(roleName) {
443
484
  if (this.store.getGlobalRole(roleName) === null) {
444
485
  throw new Error(`Global Role not found: ${roleName}.`);