@zq-silk/yui 0.15.9 → 0.15.11

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 (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -0
@@ -14,6 +14,13 @@ export const TASK_MESSAGE_KINDS = ["user", "operator", "role-result", "system"];
14
14
  * so an internal Agent report cannot acquire develop authority.
15
15
  */
16
16
  export const TASK_SUBMISSION_INTENTS = ["record", "discuss", "develop"];
17
+ /** The two durable input actions an authorized caller can choose (decision-3).
18
+ * `interrupt` is a control operation, not a persisted Message action, so it is
19
+ * absent here. */
20
+ export const TASK_MESSAGE_INPUT_ACTIONS = ["queue", "steer"];
21
+ /** The recordable outcomes of a steer's one live control attempt (message-5 gap
22
+ * D). `not-submitted` is not here: it is the absence of a record, not a value. */
23
+ export const TASK_MESSAGE_INPUT_CONTROL_OUTCOMES = ["pending", "accepted", "rejected", "delivery-unknown"];
17
24
  export function createTaskMessage(id, taskId, body, kind, author, now, context = {}) {
18
25
  validateKindAndAuthor(kind, author);
19
26
  const message = {
@@ -32,6 +39,38 @@ export function createTaskMessage(id, taskId, body, kind, author, now, context =
32
39
  ...(context.submissionKey === undefined
33
40
  ? {}
34
41
  : { submissionKey: requireText(context.submissionKey, "Message submission key") }),
42
+ ...(context.inputControl === undefined
43
+ ? {}
44
+ : { inputControl: {
45
+ action: context.inputControl.action,
46
+ requestId: requireSafeIdentity(context.inputControl.requestId, "Message input requestId"),
47
+ ...(context.inputControl.expectedTarget === undefined
48
+ ? {} : { expectedTarget: requireText(context.inputControl.expectedTarget, "Message input expectedTarget") })
49
+ } }),
50
+ ...(context.interruptThen === undefined
51
+ ? {}
52
+ : { interruptThen: {
53
+ requestId: requireSafeIdentity(context.interruptThen.requestId, "Message interrupt requestId"),
54
+ targetAttemptId: requireText(context.interruptThen.targetAttemptId, "Message interrupt targetAttemptId"),
55
+ targetRoleName: context.interruptThen.targetRoleName,
56
+ targetAgentId: context.interruptThen.targetAgentId,
57
+ targetAdapterId: context.interruptThen.targetAdapterId,
58
+ targetNativeSessionId: context.interruptThen.targetNativeSessionId,
59
+ targetAuthorityEpoch: context.interruptThen.targetAuthorityEpoch,
60
+ targetAuthorityHolderId: context.interruptThen.targetAuthorityHolderId,
61
+ ...(context.interruptThen.targetNativeTurnId === undefined ? {} : {
62
+ targetNativeTurnId: context.interruptThen.targetNativeTurnId
63
+ }),
64
+ ...(context.interruptThen.notDeliveredReason === undefined ? {} : {
65
+ notDeliveredReason: context.interruptThen.notDeliveredReason
66
+ }),
67
+ ...(context.interruptThen.targetRunId === undefined
68
+ ? {} : { targetRunId: context.interruptThen.targetRunId }),
69
+ ...(context.interruptThen.reusedInput === undefined ? {} : { reusedInput: {
70
+ action: context.interruptThen.reusedInput.action,
71
+ requestId: requireSafeIdentity(context.interruptThen.reusedInput.requestId, "Reused input requestId")
72
+ } })
73
+ } }),
35
74
  ...(context.runId === undefined
36
75
  ? {}
37
76
  : { runId: requireSafeIdentity(context.runId, "Message AgentRun id") }),
@@ -49,6 +88,69 @@ export function createTaskMessage(id, taskId, body, kind, author, now, context =
49
88
  export function taskMessageAuthorLabel(author) {
50
89
  return author.type === "role" ? author.roleName : author.type;
51
90
  }
91
+ /**
92
+ * The four visible states a later reader distinguishes for a steer input
93
+ * (message-5 gap D): the absence of any live attempt is `not-submitted`, and a
94
+ * recorded attempt reports its own observed outcome. A non-steer Message has no
95
+ * control state and returns `undefined`. This is the single reader other CLI
96
+ * invocations, the Web surface, and the Leader's Context read all use, so no
97
+ * consumer reconstructs the state from raw fields.
98
+ */
99
+ export function taskMessageInputControlState(message) {
100
+ const wasSteer = message.inputControl?.action === "steer"
101
+ || message.interruptThen?.reusedInput?.action === "steer";
102
+ if (!wasSteer)
103
+ return undefined;
104
+ return message.control?.outcome ?? "not-submitted";
105
+ }
106
+ /**
107
+ * Record the observed disposition of a steer's one live control attempt as an
108
+ * independent, idempotent, monotonic control op (decision-3 §3/§8, message-5 gap
109
+ * D). The op is keyed by the exact `receiptId` fence and the steer's own
110
+ * `requestId`; a record naming a different Message's receipt or a different
111
+ * input identity is refused, so an outcome can never be folded onto the wrong
112
+ * Message or fork a second op. Terminals never rewind to `pending` and never
113
+ * overwrite a different terminal — a repeated or out-of-order live/Host record
114
+ * is absorbed, never a conflicting second write. The frozen business
115
+ * {@link TaskMessageInputControl} intent is left untouched.
116
+ */
117
+ export function recordTaskMessageControlOutcome(message, record) {
118
+ const inputRequestId = message.inputControl?.requestId ?? message.interruptThen?.reusedInput?.requestId;
119
+ const wasSteer = message.inputControl?.action === "steer"
120
+ || message.interruptThen?.reusedInput?.action === "steer";
121
+ if (!wasSteer || inputRequestId === undefined) {
122
+ throw new Error("Only a steer input Message records a live control outcome.");
123
+ }
124
+ if (record.requestId !== inputRequestId) {
125
+ throw new Error("Control outcome requestId must match the steer input identity.");
126
+ }
127
+ const existing = message.control;
128
+ if (existing !== undefined && existing.receiptId !== record.receiptId) {
129
+ throw new Error("Control outcome receiptId cannot name another control op.");
130
+ }
131
+ // Monotonic freeze (decision-3 §8, message-5 gap D). Once any terminal is
132
+ // proven it is final: a later `pending` never rewinds it and a different
133
+ // terminal never overwrites it — the first proven terminal wins. While still
134
+ // `pending`, an identical repeat is idempotent. Each frozen/absorbed case
135
+ // returns the Message unchanged so a fold can persist without a second write.
136
+ if (existing !== undefined) {
137
+ if (existing.outcome === "accepted" || existing.outcome === "rejected")
138
+ return message;
139
+ if (record.outcome === "pending" || record.outcome === existing.outcome)
140
+ return message;
141
+ }
142
+ const updated = {
143
+ ...message,
144
+ control: {
145
+ requestId: requireSafeIdentity(record.requestId, "Message control requestId"),
146
+ receiptId: requireText(record.receiptId, "Message control receiptId"),
147
+ outcome: record.outcome,
148
+ observedAt: record.observedAt.toISOString()
149
+ }
150
+ };
151
+ validateTaskMessage(updated);
152
+ return updated;
153
+ }
52
154
  /** A role-result reference never duplicates the execution's report body. */
53
155
  export function expandTaskMessageResult(message, getRun) {
54
156
  if (message.resultRef === undefined)
@@ -130,6 +232,72 @@ export function validateTaskMessage(message) {
130
232
  if (message.submissionReceipt !== undefined && message.submissionKey === undefined) {
131
233
  throw new Error("Message submission receipt requires a submission key.");
132
234
  }
235
+ if (message.inputControl !== undefined) {
236
+ if (!TASK_MESSAGE_INPUT_ACTIONS.includes(message.inputControl.action)) {
237
+ throw new Error(`Message input action is invalid: ${String(message.inputControl.action)}.`);
238
+ }
239
+ requireSafeIdentity(message.inputControl.requestId, "Message input requestId");
240
+ if (message.inputControl.expectedTarget !== undefined) {
241
+ requireText(message.inputControl.expectedTarget, "Message input expectedTarget");
242
+ if (message.inputControl.action !== "steer") {
243
+ throw new Error("Only a steer input binds an expectedTarget.");
244
+ }
245
+ }
246
+ }
247
+ if (message.interruptThen !== undefined) {
248
+ requireSafeIdentity(message.interruptThen.requestId, "Message interrupt requestId");
249
+ requireText(message.interruptThen.targetAttemptId, "Message interrupt targetAttemptId");
250
+ requireSafeIdentity(message.interruptThen.targetRoleName, "Message interrupt target Role");
251
+ requireSafeIdentity(message.interruptThen.targetAgentId, "Message interrupt target Agent");
252
+ requireSafeIdentity(message.interruptThen.targetAdapterId, "Message interrupt target Adapter");
253
+ requireText(message.interruptThen.targetNativeSessionId, "Message interrupt target Session");
254
+ requireText(message.interruptThen.targetAuthorityHolderId, "Message interrupt authority holder");
255
+ if (message.interruptThen.notDeliveredReason !== undefined) {
256
+ requireText(message.interruptThen.notDeliveredReason, "Message interrupt nondelivery reason");
257
+ }
258
+ if (!Number.isSafeInteger(message.interruptThen.targetAuthorityEpoch)
259
+ || message.interruptThen.targetAuthorityEpoch < 1)
260
+ throw new Error("Invalid interrupt authority epoch.");
261
+ if (message.interruptThen.targetRunId !== undefined) {
262
+ validateTaskRecordReference({ taskId: message.taskId, localId: message.interruptThen.targetRunId }, "run");
263
+ }
264
+ if (message.interruptThen.reusedInput !== undefined) {
265
+ if (!TASK_MESSAGE_INPUT_ACTIONS.includes(message.interruptThen.reusedInput.action)) {
266
+ throw new Error(`Reused input action is invalid: ${String(message.interruptThen.reusedInput.action)}.`);
267
+ }
268
+ requireSafeIdentity(message.interruptThen.reusedInput.requestId, "Reused input requestId");
269
+ }
270
+ // A then-handoff is an explicit deliverable Message, never an auto-queued
271
+ // steer: the two intents are mutually exclusive by construction. The prior
272
+ // steer identity, when one existed, is preserved as reusedInput provenance
273
+ // rather than left live on inputControl.
274
+ if (message.inputControl?.action === "steer") {
275
+ throw new Error("An interrupt-then handoff cannot also be a steer input.");
276
+ }
277
+ }
278
+ if (message.control !== undefined) {
279
+ requireSafeIdentity(message.control.requestId, "Message control requestId");
280
+ requireText(message.control.receiptId, "Message control receiptId");
281
+ if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)) {
282
+ throw new Error(`Message control outcome is invalid: ${String(message.control.outcome)}.`);
283
+ }
284
+ if (typeof message.control.observedAt !== "string" || Number.isNaN(Date.parse(message.control.observedAt))) {
285
+ throw new Error("Message control observedAt is invalid.");
286
+ }
287
+ // A recorded control outcome is the disposition of a live steer attempt; it
288
+ // only exists for a steer input, or for the reused-steer provenance a
289
+ // handoff preserved. It is never fabricated on a plain queue or a Message
290
+ // that never carried a steer (decision-3 §3, message-5 gap D).
291
+ const inputRequestId = message.inputControl?.requestId ?? message.interruptThen?.reusedInput?.requestId;
292
+ const wasSteer = message.inputControl?.action === "steer"
293
+ || message.interruptThen?.reusedInput?.action === "steer";
294
+ if (!wasSteer || inputRequestId === undefined) {
295
+ throw new Error("Only a steer input Message records a live control outcome.");
296
+ }
297
+ if (message.control.requestId !== inputRequestId) {
298
+ throw new Error("Message control requestId must match the steer input identity.");
299
+ }
300
+ }
133
301
  if (message.runId !== undefined)
134
302
  requireSafeIdentity(message.runId, "Message AgentRun id");
135
303
  if (message.recipient !== undefined) {
@@ -186,6 +354,205 @@ export function validateTaskMessage(message) {
186
354
  throw new Error("Message createdAt is invalid.");
187
355
  }
188
356
  }
357
+ /**
358
+ * The author kinds a Global Role input can carry. A Global Role has no Task
359
+ * Assignment, so a `role-result` (which references a Task AgentRun) is not one
360
+ * of them; user/operator/system inputs are (decision-3 §9/§11).
361
+ */
362
+ export const GLOBAL_ROLE_MESSAGE_KINDS = ["user", "operator", "system", "agent"];
363
+ export function createGlobalRoleMessage(id, roleName, body, kind, author, now, context = {}) {
364
+ validateGlobalKindAndAuthor(kind, author);
365
+ const message = {
366
+ schemaVersion: 1,
367
+ id: validateGlobalRoleMessageId(id),
368
+ roleName: requireSafeIdentity(roleName, "Global message Role name"),
369
+ kind,
370
+ author: { type: author.type },
371
+ body: requireBody(body),
372
+ ...(context.inputControl === undefined
373
+ ? {}
374
+ : { inputControl: {
375
+ action: context.inputControl.action,
376
+ requestId: requireSafeIdentity(context.inputControl.requestId, "Message input requestId"),
377
+ ...(context.inputControl.expectedTarget === undefined
378
+ ? {} : { expectedTarget: requireText(context.inputControl.expectedTarget, "Message input expectedTarget") })
379
+ } }),
380
+ createdAt: now.toISOString()
381
+ };
382
+ validateGlobalRoleMessage(message);
383
+ return message;
384
+ }
385
+ export function validateGlobalRoleMessage(message) {
386
+ if (message.schemaVersion !== 1)
387
+ throw new Error("Global Role Message must use schemaVersion 1.");
388
+ validateGlobalRoleMessageId(message.id);
389
+ requireSafeIdentity(message.roleName, "Global message Role name");
390
+ requireBody(message.body);
391
+ validateGlobalKindAndAuthor(message.kind, message.author);
392
+ if (message.deliveryTarget !== undefined) {
393
+ requireText(message.deliveryTarget.agentId, "Global input Agent");
394
+ requireText(message.deliveryTarget.nativeSessionId, "Global input Session");
395
+ }
396
+ if (message.control !== undefined) {
397
+ requireSafeIdentity(message.control.requestId, "Global input requestId");
398
+ requireText(message.control.receiptId, "Global input receiptId");
399
+ if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)
400
+ || !Number.isFinite(Date.parse(message.control.observedAt)))
401
+ throw new Error("Global input disposition is invalid.");
402
+ }
403
+ if (message.inputControl !== undefined) {
404
+ if (!TASK_MESSAGE_INPUT_ACTIONS.includes(message.inputControl.action)) {
405
+ throw new Error(`Message input action is invalid: ${String(message.inputControl.action)}.`);
406
+ }
407
+ requireSafeIdentity(message.inputControl.requestId, "Message input requestId");
408
+ if (message.inputControl.expectedTarget !== undefined) {
409
+ requireText(message.inputControl.expectedTarget, "Message input expectedTarget");
410
+ if (message.inputControl.action !== "steer") {
411
+ throw new Error("Only a steer input binds an expectedTarget.");
412
+ }
413
+ }
414
+ }
415
+ if (message.delivery !== undefined) {
416
+ if (message.delivery.via !== "provider" && message.delivery.via !== "transport") {
417
+ throw new Error(`Global message delivery via is invalid: ${String(message.delivery.via)}.`);
418
+ }
419
+ if (typeof message.delivery.deliveredAt !== "string"
420
+ || Number.isNaN(Date.parse(message.delivery.deliveredAt))) {
421
+ throw new Error("Global message delivery deliveredAt is invalid.");
422
+ }
423
+ // Receipt evidence is shared by queue, explicit then and native steer.
424
+ if (message.inputControl === undefined && message.interruptThen === undefined) {
425
+ throw new Error("Only a durable Global input records Provider delivery.");
426
+ }
427
+ }
428
+ if (message.interruptThen !== undefined) {
429
+ requireSafeIdentity(message.interruptThen.requestId, "Global message interrupt requestId");
430
+ requireText(message.interruptThen.targetAttemptId, "Global message interrupt targetAttemptId");
431
+ requireText(message.interruptThen.targetNativeSessionId, "Global interrupt target Session");
432
+ requireText(message.interruptThen.targetAgentId, "Global interrupt target Agent");
433
+ requireText(message.interruptThen.targetAuthorityHolderId, "Global interrupt writer");
434
+ if (!Number.isSafeInteger(message.interruptThen.targetAuthorityEpoch)
435
+ || message.interruptThen.targetAuthorityEpoch < 1)
436
+ throw new Error("Global interrupt epoch is invalid.");
437
+ if (message.interruptThen.targetNativeTurnId !== undefined) {
438
+ requireText(message.interruptThen.targetNativeTurnId, "Global message interrupt targetNativeTurnId");
439
+ }
440
+ if (message.interruptThen.reusedInput !== undefined) {
441
+ if (!TASK_MESSAGE_INPUT_ACTIONS.includes(message.interruptThen.reusedInput.action)) {
442
+ throw new Error(`Reused input action is invalid: ${String(message.interruptThen.reusedInput.action)}.`);
443
+ }
444
+ requireSafeIdentity(message.interruptThen.reusedInput.requestId, "Reused input requestId");
445
+ }
446
+ }
447
+ if (message.notDelivered !== undefined) {
448
+ requireText(message.notDelivered.reason, "Global message nondelivery reason");
449
+ if (typeof message.notDelivered.at !== "string"
450
+ || Number.isNaN(Date.parse(message.notDelivered.at))) {
451
+ throw new Error("Global message nondelivery timestamp is invalid.");
452
+ }
453
+ // A delivered Message is a settled positive fact; a not-delivered fact is its
454
+ // visible negative twin. The two are mutually exclusive on one Message.
455
+ if (message.delivery !== undefined) {
456
+ throw new Error("A Global message cannot be both delivered and not-delivered.");
457
+ }
458
+ if (message.inputControl?.action !== "queue" && message.interruptThen === undefined) {
459
+ throw new Error("Only a queued or interrupt-then Global message records a nondelivery.");
460
+ }
461
+ }
462
+ if (typeof message.createdAt !== "string" || Number.isNaN(Date.parse(message.createdAt))) {
463
+ throw new Error("Message createdAt is invalid.");
464
+ }
465
+ }
466
+ /** A Global message local id is `global-message-<n>`; it carries no Task id. */
467
+ export function validateGlobalRoleMessageId(localId) {
468
+ const normalized = requireSafeIdentity(localId, "Global message id");
469
+ if (!/^global-message-[1-9]\d*$/.test(normalized)) {
470
+ throw new Error(`Global message id is invalid: ${localId}.`);
471
+ }
472
+ return normalized;
473
+ }
474
+ /**
475
+ * Return a copy of a queued Global Message marked delivered at its next legal
476
+ * opportunity — the Role's own authorized Context read (decision-3 §9). Only a
477
+ * `queue` input may be consumed this way and only once: a Message that already
478
+ * carries a delivery is returned unchanged, so a repeated self-read is
479
+ * idempotent and never re-delivers. A steer/interrupt Message, which targets the
480
+ * exact current Turn rather than a queued next opportunity, is never delivered
481
+ * here.
482
+ */
483
+ export function markGlobalRoleMessageDelivered(message, now, via = "provider") {
484
+ if (message.inputControl === undefined && message.interruptThen === undefined) {
485
+ throw new Error("Only a durable Global input is delivered by a Provider.");
486
+ }
487
+ if (message.delivery?.via === "provider" || message.delivery?.via === via)
488
+ return message;
489
+ const delivered = {
490
+ ...message,
491
+ ...(message.control === undefined ? {} : { control: {
492
+ ...message.control, outcome: via === "provider" ? "accepted" : "pending", observedAt: now.toISOString()
493
+ } }),
494
+ delivery: { deliveredAt: now.toISOString(), via }
495
+ };
496
+ validateGlobalRoleMessage(delivered);
497
+ return delivered;
498
+ }
499
+ /**
500
+ * Return a copy of an already-saved durable Global Message that claims the single
501
+ * interrupt-then continuation of an exact interrupted native Turn (decision-3
502
+ * §4). The original input identity, when the Message carried one, is preserved as
503
+ * `reusedInput` provenance rather than erased, so a replay of that requestId
504
+ * keeps resolving to this same Message and never creates a second input.
505
+ */
506
+ export function claimGlobalRoleMessageInterruptThen(message, claim) {
507
+ const { inputControl, notDelivered: _priorNondelivery, ...rest } = message;
508
+ const claimed = {
509
+ ...rest,
510
+ interruptThen: {
511
+ requestId: requireSafeIdentity(claim.requestId, "Global message interrupt requestId"),
512
+ targetAttemptId: requireText(claim.targetAttemptId, "Global message interrupt targetAttemptId"),
513
+ targetNativeSessionId: claim.targetNativeSessionId,
514
+ targetAgentId: claim.targetAgentId,
515
+ targetAuthorityEpoch: claim.targetAuthorityEpoch,
516
+ targetAuthorityHolderId: claim.targetAuthorityHolderId,
517
+ ...(claim.targetNativeTurnId === undefined
518
+ ? {}
519
+ : { targetNativeTurnId: requireText(claim.targetNativeTurnId, "Global message interrupt targetNativeTurnId") }),
520
+ ...(inputControl === undefined ? {} : { reusedInput: inputControl })
521
+ }
522
+ };
523
+ validateGlobalRoleMessage(claimed);
524
+ return claimed;
525
+ }
526
+ /**
527
+ * Return a copy of a durable Global Message marked visibly not-delivered
528
+ * (decision-3 §5/§10), the Global twin of the Task {@link markNotDelivered}. An
529
+ * ordinary queue entry or a claimed interrupt-then handoff whose target can never
530
+ * prove a safe boundary fails here and stops holding the Role's pending set,
531
+ * rather than silently wedging it. It is idempotent for the same reason — a
532
+ * Message already carrying this exact reason is returned unchanged — and a
533
+ * delivered Message is never overwritten with a nondelivery.
534
+ */
535
+ export function markGlobalRoleMessageNotDelivered(message, reason, now) {
536
+ if (message.delivery !== undefined) {
537
+ throw new Error("A delivered Global message cannot be marked not-delivered.");
538
+ }
539
+ if (message.notDelivered?.reason === reason)
540
+ return message;
541
+ const marked = {
542
+ ...message,
543
+ notDelivered: { reason: requireText(reason, "Global message nondelivery reason"), at: now.toISOString() }
544
+ };
545
+ validateGlobalRoleMessage(marked);
546
+ return marked;
547
+ }
548
+ function validateGlobalKindAndAuthor(kind, author) {
549
+ if (!GLOBAL_ROLE_MESSAGE_KINDS.includes(kind)) {
550
+ throw new Error(`Global message kind is invalid: ${String(kind)}.`);
551
+ }
552
+ if (author?.type !== kind) {
553
+ throw new Error(`Global message kind ${kind} requires a ${kind} author.`);
554
+ }
555
+ }
189
556
  function validateKindAndAuthor(kind, author) {
190
557
  if (!TASK_MESSAGE_KINDS.includes(kind))
191
558
  throw new Error(`Message kind is invalid: ${String(kind)}.`);
@@ -8,6 +8,7 @@ import { createTaskEvent } from "../event/taskEvent.js";
8
8
  import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
9
9
  import { sourceRunContextValue } from "../context/sourceRunContext.js";
10
10
  import { requireManagedTaskCaller } from "../runtime/managedCaller.js";
11
+ import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
11
12
  export function resolveMessageRecipient(store, taskId, roleName, scope) {
12
13
  if (scope.reviewRoundId !== undefined) {
13
14
  const round = store.getReviewRound(taskId, scope.reviewRoundId);
@@ -95,12 +96,104 @@ export function messageContinuationBlocker(store, message) {
95
96
  }
96
97
  return undefined;
97
98
  }
99
+ /**
100
+ * The one gate that decides when a claimed interrupt-then handoff may be
101
+ * delivered (decision-3 §4). It is keyed on the exact interrupted native Turn —
102
+ * not on the recipient's owner Assignment and not on a Run's business status —
103
+ * so the terminal proof is about the Turn that was actually cancelled:
104
+ * - `ready` — the native Turn stopped and its cancel outcome is knowable; the
105
+ * handoff may deliver.
106
+ * - `waiting` — the native Turn is still in flight; hold silently until it stops.
107
+ * - `unknown` — the cancel outcome is unprovable (delivery-unknown); the handoff
108
+ * is never released or replayed across that boundary.
109
+ * - `missing` — the target can never prove its terminal.
110
+ * A plain queued Message (no claim) is always `ready` here; its own delivery
111
+ * gates live in messageContinuationBlocker.
112
+ *
113
+ * The proof is composite by necessity (message-5 gap C). Native execution
114
+ * termination is read from the live ProviderTurn, re-verified by attemptId,
115
+ * because a Run's business status can flip to failed while its native Turn is
116
+ * still running — the old AgentRun-only gate could release across a Turn that had
117
+ * not actually stopped. When the live binding no longer holds that exact Turn, the
118
+ * proof falls back to the durable native terminal observation keyed by the target
119
+ * attemptId — never to the AgentRun's business status and never to a replacement
120
+ * Turn's existence, both of which can be true while the target's native execution
121
+ * has not stopped (decision-3 §4/§8, "requested != stopped"). The cancel's
122
+ * *outcome* (clean vs delivery-unknown) lives on the owning AgentRun's
123
+ * failureReason when a Run owns the Turn, because a settled ProviderTurn — or a
124
+ * durable native terminal — preserves only completed/failed/cancelled and cannot
125
+ * itself carry delivery-unknown.
126
+ */
127
+ export function interruptThenTerminalState(store, message) {
128
+ const claim = message.interruptThen;
129
+ if (claim === undefined)
130
+ return "ready";
131
+ const roleName = claim.targetRoleName;
132
+ const sessions = store.getTaskRoleSessionSet(message.taskId, roleName);
133
+ const session = sessions?.sessions[sessions.activeAgentId];
134
+ const binding = sessions?.providerBinding;
135
+ if (session?.status !== "active" || session.nativeSessionId !== claim.targetNativeSessionId
136
+ || session.agentId !== claim.targetAgentId || session.adapterId !== claim.targetAdapterId
137
+ || binding?.authority.owner !== "controller"
138
+ || binding.authority.epoch !== claim.targetAuthorityEpoch
139
+ || binding.authority.holderId !== claim.targetAuthorityHolderId)
140
+ return "missing";
141
+ const turn = binding.run;
142
+ const owner = claim.targetRunId === undefined ? null : store.getRun(message.taskId, claim.targetRunId);
143
+ if (claim.targetRunId !== undefined && owner === null)
144
+ return "missing";
145
+ // Primary proof: the exact interrupted native Turn, identity re-verified by
146
+ // attemptId. While it is still present — in flight (submitting/accepted) or its
147
+ // own acceptance unproven (delivery-unknown) — the native execution has not
148
+ // stopped, so the handoff waits no matter what the Run's status claims.
149
+ if (turn !== null && turn.attemptId === claim.targetAttemptId) {
150
+ if (["submitting", "accepted", "delivery-unknown"].includes(turn.status))
151
+ return "waiting";
152
+ // The native Turn stopped. Its cancel outcome may still be unprovable; that
153
+ // is recorded on the owning AgentRun, never on the settled ProviderTurn.
154
+ return ["completed", "failed", "cancelled"].includes(turn.status)
155
+ && owner?.result?.failureReason !== "delivery-unknown" ? "ready" : "unknown";
156
+ }
157
+ // The live binding no longer holds the target native Turn — a later Turn
158
+ // occupies it, or the binding's run pointer is gone. decision-3 §4/§8: a
159
+ // replacement Turn's mere existence is NOT proof the target's native execution
160
+ // stopped ("requested != stopped"; native quiescence is never inferred from a
161
+ // new Turn's existence, and never from the owning AgentRun's business status).
162
+ // The only protocol-proven stop is the durable, immutable native terminal
163
+ // observation keyed by the exact target attemptId, written when the Provider
164
+ // Host observed the Turn actually terminate.
165
+ const nativeStopped = store.listEvents(message.taskId)
166
+ .map(runtimeObservationFromTaskEvent)
167
+ .some((observation) => observation !== null
168
+ && (observation.kind === "turn.completed" || observation.kind === "turn.failed"
169
+ || observation.kind === "turn.cancelled")
170
+ && observation.fence.roleName === roleName
171
+ && observation.fence.nativeSessionId === claim.targetNativeSessionId
172
+ && (claim.targetNativeTurnId === undefined || observation.fence.nativeTurnId === claim.targetNativeTurnId)
173
+ && observation.fence.receiptId === claim.targetAttemptId);
174
+ if (nativeStopped) {
175
+ // Proven stopped. Its cancel outcome (clean vs delivery-unknown) is recorded on
176
+ // the owning AgentRun, never on the settled native terminal itself — the same
177
+ // composite the primary path reads once native termination is proven.
178
+ return owner?.result?.failureReason === "delivery-unknown" ? "unknown" : "ready";
179
+ }
180
+ // No durable proof the target native Turn stopped. Hold silently while an owning
181
+ // Run could still produce that terminal; a later business-terminal or a
182
+ // replacement Turn must never release it. With nothing owning the Turn and no
183
+ // durable terminal, its stop is unobservable and the claim can never prove a
184
+ // safe boundary — never released or replayed either way.
185
+ return owner !== null ? "waiting" : "missing";
186
+ }
98
187
  /** Called in the Controller's ordinary reconciliation transaction. Messages
99
188
  * remain the pending authority; Mailbox is only the existing scheduling hint.
100
189
  * Reserving inputs and creating the next Run is one atomic effect. */
101
190
  export function prepareMessageContinuations(store, taskId, now, roleName) {
102
191
  const pending = store.listMessages(taskId).filter((message) => message.recipient?.roleName === roleName && message.recipient.ownerRunId !== undefined
103
- && message.continuation?.runId === undefined);
192
+ && message.continuation?.runId === undefined
193
+ // A steer targets the exact current native turn, never a queued next Run.
194
+ // If its live attempt did not submit, the message stays saved for the
195
+ // Leader to re-choose (§1/§8); Core never silently turns it into a queue.
196
+ && message.inputControl?.action !== "steer");
104
197
  for (const message of pending) {
105
198
  const recipient = message.recipient;
106
199
  const blocker = messageContinuationBlocker(store, message);
@@ -138,8 +231,38 @@ export function prepareMessageContinuations(store, taskId, now, roleName) {
138
231
  ? "owner-session-unobserved" : "owner-session-changed");
139
232
  continue;
140
233
  }
141
- const batch = pending.filter((entry) => isDeepStrictEqual(entry.recipient, recipient)
142
- && messageContinuationBlocker(store, entry) === undefined).slice(0, 16);
234
+ // decision-3 §4 ordering. Split this recipient's deliverable Messages into
235
+ // the explicit interrupt-then handoffs and the ordinary queue. A handoff is
236
+ // released only after its exact interrupted AgentRun reaches a proven
237
+ // terminal, and the ordinary queue must never preempt a live handoff.
238
+ const recipientPending = pending.filter((entry) => isDeepStrictEqual(entry.recipient, recipient)
239
+ && messageContinuationBlocker(store, entry) === undefined);
240
+ const thenClaims = recipientPending.filter((entry) => entry.interruptThen !== undefined);
241
+ let handoffWaiting = false;
242
+ const readyThen = [];
243
+ for (const claim of thenClaims) {
244
+ const state = interruptThenTerminalState(store, claim);
245
+ if (state === "ready")
246
+ readyThen.push(claim);
247
+ else if (state === "waiting")
248
+ handoffWaiting = true;
249
+ // A claim whose target can never prove its terminal fails visibly and
250
+ // stops holding the queue; it is never released or replayed.
251
+ else
252
+ markNotDelivered(store, claim, state === "unknown"
253
+ ? "interrupt-then-target-delivery-unknown" : "interrupt-then-target-missing");
254
+ }
255
+ // Hold the entire recipient — including the ordinary queue — while any
256
+ // handoff still awaits its interrupted Turn's proven terminal.
257
+ if (handoffWaiting)
258
+ continue;
259
+ // A ready handoff delivers in its own round ahead of the ordinary queue, so
260
+ // the old queue only follows once the handoff has been delivered.
261
+ const batch = (readyThen.length > 0
262
+ ? readyThen
263
+ : recipientPending.filter((entry) => entry.interruptThen === undefined)).slice(0, 16);
264
+ if (batch.length === 0)
265
+ continue;
143
266
  const previous = store.listRuns(taskId).filter((run) => run.roleName === owner.roleName
144
267
  && run.workItemId === owner.workItemId && run.reviewRoundId === owner.reviewRoundId).at(-1);
145
268
  const baselineRef = previous.inputs[0]?.input.contextSnapshotRef;
@@ -0,0 +1,34 @@
1
+ import { createTaskEvent } from "../event/taskEvent.js";
2
+ /** Interrupt attempts are execution evidence, not a second message queue. */
3
+ export function findTaskInterrupt(store, taskId, requestId) {
4
+ return store.listEvents(taskId).find(event => event.type === "input.interrupt-requested" && event.payload.requestId === requestId);
5
+ }
6
+ export function reserveTaskInterrupt(store, taskId, requestId, fingerprint, target, thenMessageId, now) {
7
+ const receiptId = `interrupt:${taskId}/${requestId}`;
8
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, "input.interrupt-requested", {
9
+ requestId, receiptId, fingerprint, roleName: target.roleName,
10
+ nativeSessionId: target.nativeSessionId, attemptId: target.attemptId,
11
+ target: JSON.stringify(target),
12
+ ...(thenMessageId === undefined ? {} : { thenMessageId })
13
+ }, now));
14
+ return receiptId;
15
+ }
16
+ export function recordTaskInterruptResult(store, taskId, receiptId, receipt, now = new Date()) {
17
+ store.transaction(tx => {
18
+ const requested = tx.listEvents(taskId).find(event => event.type === "input.interrupt-requested" && event.payload.receiptId === receiptId);
19
+ if (requested === undefined)
20
+ throw new Error("Interrupt receipt has no original request.");
21
+ if (tx.listEvents(taskId).some(event => event.type === "input.interrupt-result" && event.payload.receiptId === receiptId))
22
+ return;
23
+ tx.saveEvent(taskId, createTaskEvent(tx.nextEventId(taskId), taskId, "input.interrupt-result", { receiptId, receipt: JSON.stringify(receipt) }, now));
24
+ });
25
+ }
26
+ export function taskInterruptReceipt(store, taskId, receiptId) {
27
+ const event = store.listEvents(taskId).find(entry => entry.type === "input.interrupt-result" && entry.payload.receiptId === receiptId);
28
+ return event === undefined ? { state: "interrupt-unknown", receiptId }
29
+ : JSON.parse(event.payload.receipt);
30
+ }
31
+ export function taskInterruptWasRejected(store, taskId, receiptId) {
32
+ const receipt = taskInterruptReceipt(store, taskId, receiptId);
33
+ return receipt.outcome === "rejected" && receipt.state === "interrupt-unavailable";
34
+ }
@@ -135,7 +135,7 @@ function terminalWorkspaceCount(facts) {
135
135
  }).length;
136
136
  }
137
137
  function terminalStatus(status) {
138
- return status !== undefined && !["pending", "running", "awaiting_acceptance", "blocked", "validating"].includes(status);
138
+ return status !== undefined && !["pending", "running", "awaiting_acceptance", "blocked", "conflicted", "validating"].includes(status);
139
139
  }
140
140
  function counts(values) {
141
141
  const result = {};
@@ -121,6 +121,28 @@ async function activateLocked(ports, locked) {
121
121
  };
122
122
  }
123
123
  void beginResult;
124
+ try {
125
+ ports.runPreflight(releaseDir, home);
126
+ }
127
+ catch (error) {
128
+ try {
129
+ await ports.call(home, "controller.rollback-handover", { handoverId });
130
+ }
131
+ catch (rollbackError) {
132
+ return {
133
+ outcome: "aborted", phase: "preflight",
134
+ message: `Host compatibility failed: ${messageOf(error)}; rollback failed: ${messageOf(rollbackError)}`,
135
+ action: "No candidate was started and the release pointer is unchanged. Preserve the fence and inspect the old Controller.",
136
+ recoverable: true
137
+ };
138
+ }
139
+ return {
140
+ outcome: "aborted", phase: "preflight",
141
+ message: `Fenced compatibility preflight failed: ${messageOf(error)}`,
142
+ action: "The old Controller is serving again; the active release and Home are unchanged.",
143
+ recoverable: true
144
+ };
145
+ }
124
146
  // 5) Start the candidate and wait for its identity read-back.
125
147
  ports.spawnCandidate(home, releaseDir, handoverId);
126
148
  const candidateReady = await waitForCandidateReady(ports, home, manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);