@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.
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/cli/commandCatalog.js +26 -6
- package/dist/cli/interactionPolicy.js +3 -3
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +336 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +511 -83
- package/dist/commands/taskIntegrationCommands.js +16 -38
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/taskContext.js +38 -2
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +3 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
- package/dist/controller/globalInputDelivery.js +119 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +81 -6
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +312 -382
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +72 -15
- package/dist/repository/taskWorkspaceCoordinator.js +134 -0
- package/dist/repository/taskWorkspacePreparer.js +120 -49
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentHost.js +152 -72
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +12 -1
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +136 -4
- package/dist/storage/sqliteStore.js +40 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/web/assets/client/app.js +55 -0
- package/dist/web/assets/client/components.js +1 -0
- package/dist/web/assets/client/i18n.js +6 -0
- package/dist/web/assets/client/taskSurface.js +106 -0
- package/dist/web/assets/client/view.js +7 -4
- package/dist/web/assets/shell.js +23 -0
- package/dist/web/assets/styles/layout.js +1 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +117 -1
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/testing/verification-levels.md +24 -0
- package/docs/testing/verification-levels.zh-CN.md +11 -0
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +151 -49
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +6 -1
- package/skills/yui-runtime/SKILL.md +27 -0
- package/skills/yui-runtime/references/publication.md +20 -0
package/dist/message/message.js
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
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);
|