@ikon85/agent-workflow-kit 0.44.1 → 0.45.0
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/.agents/skills/audit-skills/SKILL.md +7 -4
- package/.agents/skills/code-review/SKILL.md +7 -4
- package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
- package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
- package/.agents/skills/improve-codebase-architecture/SKILL.md +7 -4
- package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
- package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
- package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
- package/.agents/skills/research/SKILL.md +7 -4
- package/.agents/skills/to-issues/SKILL.md +25 -4
- package/.claude/skills/audit-skills/SKILL.md +7 -4
- package/.claude/skills/code-review/SKILL.md +7 -4
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
- package/.claude/skills/codex-build/SKILL.md +13 -0
- package/.claude/skills/codex-review/SKILL.md +13 -0
- package/.claude/skills/grill-me-codex/SKILL.md +14 -0
- package/.claude/skills/grill-with-docs-codex/SKILL.md +14 -0
- package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
- package/.claude/skills/improve-codebase-architecture/SKILL.md +7 -4
- package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
- package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
- package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
- package/.claude/skills/research/SKILL.md +7 -4
- package/.claude/skills/skill-manifest.json +34 -23
- package/.claude/skills/to-issues/SKILL.md +25 -4
- package/README.md +71 -0
- package/agent-workflow-kit.package.json +149 -45
- package/package.json +1 -1
- package/scripts/doctrine-migration/index.mjs +296 -0
- package/scripts/kit-release.mjs +41 -9
- package/src/cli.mjs +521 -80
- package/src/commands/routing-status.mjs +288 -0
- package/src/commands/update.mjs +9 -1
- package/src/consumer-migrations.json +23 -1
- package/src/lib/bundle.mjs +158 -2
- package/src/lib/consumerMigrations.mjs +55 -0
- package/src/lib/dispatchJournal.mjs +300 -0
- package/src/lib/dispatchPlan.mjs +286 -0
- package/src/lib/dispatchReceipt.mjs +226 -89
- package/src/lib/frontendWorkloads.mjs +35 -33
- package/src/lib/routeDispatcher.mjs +367 -70
- package/src/lib/routingAccessGraph.mjs +265 -20
- package/src/lib/routingAccessGraphStore.mjs +300 -0
- package/src/lib/routingAdapters/claude.mjs +104 -4
- package/src/lib/routingAdapters/codex.mjs +132 -7
- package/src/lib/routingAdapters/hostBridge.mjs +291 -0
- package/src/lib/routingCatalog.mjs +201 -24
- package/src/lib/routingDispatchLease.mjs +253 -0
- package/src/lib/routingEvidenceCache.mjs +78 -0
- package/src/lib/routingIntent.mjs +176 -10
- package/src/lib/routingIntentClassifier.mjs +137 -0
- package/src/lib/routingInventory/snapshots/claude.json +49 -0
- package/src/lib/routingInventory/snapshots/codex.json +109 -0
- package/src/lib/routingInventory.mjs +182 -0
- package/src/lib/routingPolicy.mjs +193 -6
- package/src/lib/routingProfile.mjs +1251 -123
- package/src/lib/routingProfilePolicy.mjs +156 -0
- package/src/lib/routingProfileStorage.mjs +299 -0
- package/src/lib/routingResolver.mjs +369 -86
- package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
- package/src/lib/routingSources/benchlm.mjs +5 -0
- package/src/lib/routingSources/codeArena.mjs +13 -3
- package/src/lib/routingSources/deepswe.mjs +19 -5
- package/src/lib/routingSources/openhands.mjs +17 -4
- package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
- package/src/lib/safeText.mjs +26 -0
- package/src/lib/updateCandidate.mjs +36 -3
|
@@ -1,5 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatching a resolved Route decision.
|
|
3
|
+
*
|
|
4
|
+
* The dispatch holds a revision-bound dispatch lease across the spawn handoff
|
|
5
|
+
* whenever one is configured: the store is re-read and the Routing policy
|
|
6
|
+
* recomputed before the adapter prepares, and again after it, so an
|
|
7
|
+
* authorization revoked in that window fails the dispatch instead of racing it.
|
|
8
|
+
* Without a lease the only remaining guard is the in-process comparison of the
|
|
9
|
+
* caller's own resolver input, which cannot see another process at all.
|
|
10
|
+
*
|
|
11
|
+
* A lease survives no crash, so a configured dispatch journal carries the other
|
|
12
|
+
* half: the `prepared` entry is written before the spawn and its terminal entry
|
|
13
|
+
* after, and a dispatch that finds an indeterminate entry from an earlier run
|
|
14
|
+
* blocks pending reconciliation instead of spawning a second agent — unless the
|
|
15
|
+
* surface pre-assigned a spawn id that makes the retry provably safe.
|
|
16
|
+
*
|
|
17
|
+
* A dispatch that runs under a Dispatch plan references its authorization record
|
|
18
|
+
* before anything else happens: the plan is re-checked against the record and the
|
|
19
|
+
* unit's authorized route, and a mismatch blocks pending a newly attributed
|
|
20
|
+
* authorization instead of quietly running something the user never saw.
|
|
21
|
+
*
|
|
22
|
+
* A Route decision that is not `ready` takes one of three paths rather than one
|
|
23
|
+
* blocked receipt: `handoff` hands the work back with the resolved intent,
|
|
24
|
+
* `inherit` is the constrained non-AFK path that runs the session default only
|
|
25
|
+
* when that pair is attested by a readback channel *and* inside the effective
|
|
26
|
+
* roster, and everything else — including any inherit under AFK — fails closed.
|
|
27
|
+
*/
|
|
1
28
|
import { createDispatchReceipt } from './dispatchReceipt.mjs';
|
|
29
|
+
import { authorizeDispatchUnit } from './dispatchPlan.mjs';
|
|
30
|
+
import { normalizeRosterModelId } from './routingProfile.mjs';
|
|
31
|
+
import {
|
|
32
|
+
appendDispatchJournalEntry,
|
|
33
|
+
dispatchIdempotencyKey,
|
|
34
|
+
journalEntryForReceipt,
|
|
35
|
+
planDispatchRecovery,
|
|
36
|
+
readDispatchJournal,
|
|
37
|
+
} from './dispatchJournal.mjs';
|
|
2
38
|
import { resolveRoute } from './routingResolver.mjs';
|
|
39
|
+
import { openDispatchLease } from './routingDispatchLease.mjs';
|
|
3
40
|
import {
|
|
4
41
|
assertRoutingProfileUnchanged,
|
|
5
42
|
captureRoutingProfileSnapshot,
|
|
@@ -26,6 +63,18 @@ function receiptRevisions(decision) {
|
|
|
26
63
|
);
|
|
27
64
|
}
|
|
28
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The plan-authorization a dispatch runs under: the record that bound the
|
|
68
|
+
* Dispatch plan, what the caller named, or the approval the Route decision
|
|
69
|
+
* already recorded.
|
|
70
|
+
*/
|
|
71
|
+
function authorizationId(input, decision) {
|
|
72
|
+
return input.planAuthorization?.id
|
|
73
|
+
?? input.authorizationId
|
|
74
|
+
?? decision.approval?.authorizationId
|
|
75
|
+
?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
29
78
|
function blockedReceipt(input, decision, reason, details = {}) {
|
|
30
79
|
return createDispatchReceipt({
|
|
31
80
|
executionId: input.executionId,
|
|
@@ -36,6 +85,8 @@ function blockedReceipt(input, decision, reason, details = {}) {
|
|
|
36
85
|
enforcement: details.enforcement ?? null,
|
|
37
86
|
precedence: details.precedence ?? null,
|
|
38
87
|
revisions: receiptRevisions(decision),
|
|
88
|
+
resultingAccessRevision: input.resultingAccessRevision ?? null,
|
|
89
|
+
authorizationId: authorizationId(input, decision),
|
|
39
90
|
dispatchedAt: input.dispatchedAt,
|
|
40
91
|
reason,
|
|
41
92
|
});
|
|
@@ -46,33 +97,62 @@ function decisionReason(decision) {
|
|
|
46
97
|
return `${decision.status}:${decision.reason}${blockers}`;
|
|
47
98
|
}
|
|
48
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The closed set of failure reasons a receipt may name. A message is reduced to
|
|
102
|
+
* the reason it starts with, so neither a revision value nor an adapter
|
|
103
|
+
* diagnostic can travel into a receipt.
|
|
104
|
+
*/
|
|
105
|
+
const SAFE_FAILURE_REASONS = Object.freeze([
|
|
106
|
+
'Claude route capability is not attested',
|
|
107
|
+
'route identity is incomplete',
|
|
108
|
+
'transport is not detected',
|
|
109
|
+
'transport is not callable',
|
|
110
|
+
'transport is not permitted',
|
|
111
|
+
'model control is not enforced',
|
|
112
|
+
'effort control is not enforced',
|
|
113
|
+
'model environment precedence is unverified',
|
|
114
|
+
'effort environment precedence is unverified',
|
|
115
|
+
'model applied value is unverified',
|
|
116
|
+
'effort applied value is unverified',
|
|
117
|
+
'transport has no approved dispatcher',
|
|
118
|
+
'environment precedence mismatch: model',
|
|
119
|
+
'environment precedence mismatch: effort',
|
|
120
|
+
'applied route mismatch',
|
|
121
|
+
'spawn guard received no callable dispatcher',
|
|
122
|
+
'applied route differs from requested route',
|
|
123
|
+
'applied enforcement differs from attested access path',
|
|
124
|
+
'AFK dispatch requires enforced model and effort selection',
|
|
125
|
+
'AFK dispatch requires verified environment precedence',
|
|
126
|
+
'concurrent routing profile mutation',
|
|
127
|
+
'concurrent evidence catalog mutation',
|
|
128
|
+
'routing profile store carries no committed authorization',
|
|
129
|
+
'dispatch lease is reserved for a writer',
|
|
130
|
+
'dispatch lease is held',
|
|
131
|
+
'dispatch lease superseded',
|
|
132
|
+
'dispatch lease expired',
|
|
133
|
+
'dispatch is indeterminate pending reconciliation',
|
|
134
|
+
'dispatch is already recorded',
|
|
135
|
+
'dispatch plan authorization does not cover this dispatch',
|
|
136
|
+
'dispatch plan authorization names no such unit',
|
|
137
|
+
'dispatch route differs from the authorized dispatch plan',
|
|
138
|
+
'inherit requires an attested session-default pair',
|
|
139
|
+
'session-default pair is not in the effective roster',
|
|
140
|
+
'AFK dispatch cannot inherit a session default',
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The two reasons the journal itself raised. Recording a terminal entry for
|
|
145
|
+
* them would settle the very indeterminacy that has to stay open until it is
|
|
146
|
+
* reconciled, so a dispatch blocked by the guard writes nothing.
|
|
147
|
+
*/
|
|
148
|
+
const JOURNAL_GUARD_REASONS = Object.freeze([
|
|
149
|
+
'dispatch is indeterminate pending reconciliation',
|
|
150
|
+
'dispatch is already recorded',
|
|
151
|
+
]);
|
|
152
|
+
|
|
49
153
|
function safeFailureReason(error) {
|
|
50
154
|
const message = error instanceof Error ? error.message : '';
|
|
51
|
-
|
|
52
|
-
['Claude route capability is not attested', 'Claude route capability is not attested'],
|
|
53
|
-
['route identity is incomplete', 'route identity is incomplete'],
|
|
54
|
-
['transport is not detected', 'transport is not detected'],
|
|
55
|
-
['transport is not callable', 'transport is not callable'],
|
|
56
|
-
['transport is not permitted', 'transport is not permitted'],
|
|
57
|
-
['model control is not enforced', 'model control is not enforced'],
|
|
58
|
-
['effort control is not enforced', 'effort control is not enforced'],
|
|
59
|
-
['model environment precedence is unverified', 'model environment precedence is unverified'],
|
|
60
|
-
['effort environment precedence is unverified', 'effort environment precedence is unverified'],
|
|
61
|
-
['model applied value is unverified', 'model applied value is unverified'],
|
|
62
|
-
['effort applied value is unverified', 'effort applied value is unverified'],
|
|
63
|
-
['transport has no approved dispatcher', 'transport has no approved dispatcher'],
|
|
64
|
-
['environment precedence mismatch: model', 'environment precedence mismatch: model'],
|
|
65
|
-
['environment precedence mismatch: effort', 'environment precedence mismatch: effort'],
|
|
66
|
-
['applied route mismatch', 'applied route mismatch'],
|
|
67
|
-
['spawn guard received no callable dispatcher', 'spawn guard received no callable dispatcher'],
|
|
68
|
-
['applied route differs from requested route', 'applied route differs from requested route'],
|
|
69
|
-
['applied enforcement differs from attested access path', 'applied enforcement differs from attested access path'],
|
|
70
|
-
['AFK dispatch requires enforced model and effort selection', 'AFK dispatch requires enforced model and effort selection'],
|
|
71
|
-
['AFK dispatch requires verified environment precedence', 'AFK dispatch requires verified environment precedence'],
|
|
72
|
-
['concurrent routing profile mutation', 'concurrent routing profile mutation'],
|
|
73
|
-
['concurrent evidence catalog mutation', 'concurrent evidence catalog mutation'],
|
|
74
|
-
];
|
|
75
|
-
return safeReasons.find(([prefix]) => message.startsWith(prefix))?.[1]
|
|
155
|
+
return SAFE_FAILURE_REASONS.find((reason) => message.startsWith(reason))
|
|
76
156
|
?? 'dispatch adapter rejected route';
|
|
77
157
|
}
|
|
78
158
|
|
|
@@ -92,67 +172,284 @@ function assertAppliedEnforcement(decision, applied) {
|
|
|
92
172
|
}
|
|
93
173
|
}
|
|
94
174
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
175
|
+
/**
|
|
176
|
+
* The in-process comparison over the caller's own resolver input. It catches only
|
|
177
|
+
* a mutation this process made to that object; a store-backed lease is what sees
|
|
178
|
+
* an external writer.
|
|
179
|
+
*/
|
|
180
|
+
function inProcessGuard(resolverInput) {
|
|
181
|
+
const snapshot = captureRoutingProfileSnapshot(resolverInput);
|
|
182
|
+
const catalogRevision = resolverInput.catalog.revision;
|
|
183
|
+
return () => {
|
|
184
|
+
assertRoutingProfileUnchanged(snapshot, resolverInput);
|
|
185
|
+
if (catalogRevision !== resolverInput.catalog?.revision) {
|
|
186
|
+
throw new Error('concurrent evidence catalog mutation');
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The lease is bound to the revisions the Route decision itself named. */
|
|
192
|
+
function leaseOptions(input, decision) {
|
|
193
|
+
if (input.lease == null) return null;
|
|
194
|
+
if (typeof input.lease !== 'object' || Array.isArray(input.lease)) {
|
|
195
|
+
throw new TypeError('dispatch lease options must be an object');
|
|
98
196
|
}
|
|
99
|
-
|
|
100
|
-
|
|
197
|
+
return {
|
|
198
|
+
...input.lease,
|
|
199
|
+
holder: input.lease.holder ?? input.executionId,
|
|
200
|
+
expected: decision.revisions,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function preparedReceipt(input, decision, requestedRoute, prepared) {
|
|
205
|
+
const applied = {
|
|
206
|
+
requestedRoute,
|
|
207
|
+
appliedRoute: receiptRoute(prepared.appliedRoute),
|
|
208
|
+
enforcement: prepared.enforcement,
|
|
209
|
+
precedence: prepared.precedence,
|
|
210
|
+
};
|
|
211
|
+
if (prepared.mismatchReason) {
|
|
212
|
+
return blockedReceipt(
|
|
213
|
+
input, decision, safeFailureReason(new Error(prepared.mismatchReason)), applied,
|
|
214
|
+
);
|
|
101
215
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
216
|
+
return createDispatchReceipt({
|
|
217
|
+
executionId: input.executionId,
|
|
218
|
+
kind: 'routed-dispatch',
|
|
219
|
+
afk: input.afk,
|
|
220
|
+
...applied,
|
|
221
|
+
revisions: receiptRevisions(decision),
|
|
222
|
+
authorizationId: authorizationId(input, decision),
|
|
223
|
+
dispatchedAt: input.dispatchedAt,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The journal a dispatch records into. The idempotency key is derived from the
|
|
229
|
+
* surface actually being dispatched, so a caller cannot claim a Claude spawn id
|
|
230
|
+
* for a Codex run.
|
|
231
|
+
*/
|
|
232
|
+
function journalOptions(input, decision) {
|
|
233
|
+
if (input.journal == null) return null;
|
|
234
|
+
if (typeof input.journal !== 'object' || Array.isArray(input.journal)) {
|
|
235
|
+
throw new TypeError('dispatch journal options must be an object');
|
|
236
|
+
}
|
|
237
|
+
if (typeof input.journal.file !== 'string' || input.journal.file.trim() === '') {
|
|
238
|
+
throw new TypeError('dispatch journal options need a journal file');
|
|
109
239
|
}
|
|
110
|
-
const
|
|
111
|
-
const
|
|
240
|
+
const { surfaceId, transportId } = decision.bestExecutable;
|
|
241
|
+
const cwd = input.journal.cwd;
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
file: input.journal.file,
|
|
244
|
+
cwd,
|
|
245
|
+
surfaceId,
|
|
246
|
+
transportId,
|
|
247
|
+
idempotencyKey: dispatchIdempotencyKey({
|
|
248
|
+
surfaceId, cwd, sessionId: input.journal.sessionId ?? null,
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
112
252
|
|
|
113
|
-
|
|
253
|
+
/** What an earlier run left behind decides whether this one may spawn at all. */
|
|
254
|
+
async function assertJournalPermitsDispatch(journal, executionId) {
|
|
255
|
+
const { entries, damaged } = await readDispatchJournal(journal.file);
|
|
256
|
+
const recovery = planDispatchRecovery({
|
|
257
|
+
entries,
|
|
258
|
+
damaged,
|
|
259
|
+
executionId,
|
|
260
|
+
surfaceId: journal.surfaceId,
|
|
261
|
+
idempotencyKey: journal.idempotencyKey,
|
|
262
|
+
});
|
|
263
|
+
if (recovery.state === 'settled') throw new Error('dispatch is already recorded');
|
|
264
|
+
if (recovery.state === 'blocked-pending-reconciliation') {
|
|
265
|
+
throw new Error('dispatch is indeterminate pending reconciliation');
|
|
266
|
+
}
|
|
267
|
+
return recovery;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function recordJournalEntry(journal, receipt, taskId = null) {
|
|
271
|
+
return appendDispatchJournalEntry(journal.file, journalEntryForReceipt(receipt, {
|
|
272
|
+
cwd: journal.cwd,
|
|
273
|
+
surfaceId: journal.surfaceId,
|
|
274
|
+
transportId: journal.transportId,
|
|
275
|
+
idempotencyKey: journal.idempotencyKey,
|
|
276
|
+
taskId,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function recordPreparedEntry(journal, receipt) {
|
|
281
|
+
return appendDispatchJournalEntry(journal.file, {
|
|
282
|
+
phase: 'prepared',
|
|
283
|
+
executionId: receipt.executionId,
|
|
284
|
+
surfaceId: journal.surfaceId,
|
|
285
|
+
transportId: journal.transportId,
|
|
286
|
+
cwd: journal.cwd,
|
|
287
|
+
idempotencyKey: journal.idempotencyKey,
|
|
288
|
+
authorizationId: receipt.authorizationId,
|
|
289
|
+
recordedAt: receipt.dispatchedAt,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function performDispatch(input, decision, { journal, requestedRoute }) {
|
|
294
|
+
const assertInProcessUnchanged = inProcessGuard(input.resolverInput);
|
|
295
|
+
const lease = leaseOptions(input, decision);
|
|
296
|
+
let held = null;
|
|
114
297
|
try {
|
|
298
|
+
if (journal) await assertJournalPermitsDispatch(journal, input.executionId);
|
|
299
|
+
if (lease) held = await openDispatchLease(lease);
|
|
115
300
|
const prepared = await input.adapter.prepare(requestedRoute);
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
throw new Error('concurrent evidence catalog mutation');
|
|
119
|
-
}
|
|
301
|
+
assertInProcessUnchanged();
|
|
302
|
+
if (held) await held.revalidate();
|
|
120
303
|
assertAppliedEnforcement(decision, prepared.enforcement);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
new Error(prepared.mismatchReason),
|
|
126
|
-
), {
|
|
127
|
-
requestedRoute,
|
|
128
|
-
appliedRoute: receiptRoute(prepared.appliedRoute),
|
|
129
|
-
enforcement: prepared.enforcement,
|
|
130
|
-
precedence: prepared.precedence,
|
|
131
|
-
}),
|
|
132
|
-
dispatchResult: null,
|
|
133
|
-
});
|
|
304
|
+
const receipt = preparedReceipt(input, decision, requestedRoute, prepared);
|
|
305
|
+
if (receipt.status !== 'dispatched') {
|
|
306
|
+
if (journal) await recordJournalEntry(journal, receipt);
|
|
307
|
+
return Object.freeze({ decision, receipt, dispatchResult: null });
|
|
134
308
|
}
|
|
135
|
-
const receipt = createDispatchReceipt({
|
|
136
|
-
executionId: input.executionId,
|
|
137
|
-
status: 'dispatched',
|
|
138
|
-
afk: input.afk,
|
|
139
|
-
requestedRoute,
|
|
140
|
-
appliedRoute: receiptRoute(prepared.appliedRoute),
|
|
141
|
-
enforcement: prepared.enforcement,
|
|
142
|
-
precedence: prepared.precedence,
|
|
143
|
-
revisions: receiptRevisions(decision),
|
|
144
|
-
dispatchedAt: input.dispatchedAt,
|
|
145
|
-
});
|
|
146
309
|
if (typeof prepared.dispatch !== 'function') {
|
|
147
310
|
throw new Error('spawn guard received no callable dispatcher');
|
|
148
311
|
}
|
|
312
|
+
if (journal) await recordPreparedEntry(journal, receipt);
|
|
149
313
|
const dispatchResult = safeDispatchResult(await prepared.dispatch());
|
|
314
|
+
if (journal) await recordJournalEntry(journal, receipt, dispatchResult?.taskId ?? null);
|
|
150
315
|
return Object.freeze({ decision, receipt, dispatchResult });
|
|
151
|
-
}
|
|
316
|
+
} finally {
|
|
317
|
+
held?.release();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A failed dispatch is journaled too — a terminal entry is what keeps the next
|
|
323
|
+
* run from reading this one as an unresolved crash. A journal write that fails
|
|
324
|
+
* here leaves the entry indeterminate, which blocks rather than spawns.
|
|
325
|
+
*/
|
|
326
|
+
async function blockedOutcome(input, decision, { journal, requestedRoute, error }) {
|
|
327
|
+
const reason = safeFailureReason(error);
|
|
328
|
+
const receipt = blockedReceipt(input, decision, reason, { requestedRoute });
|
|
329
|
+
if (journal && !JOURNAL_GUARD_REASONS.includes(reason)) {
|
|
330
|
+
await recordJournalEntry(journal, receipt).catch(() => {});
|
|
331
|
+
}
|
|
332
|
+
return Object.freeze({ decision, receipt, dispatchResult: null });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const blockedOnly = (input, decision, reason) => Object.freeze({
|
|
336
|
+
decision, receipt: blockedReceipt(input, decision, reason), dispatchResult: null,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The authorization gate. It runs before the dispatch paths so a mismatch can
|
|
341
|
+
* never present itself as ordinary policy behaviour, and the blocked receipt
|
|
342
|
+
* still names the record it was checked against.
|
|
343
|
+
*/
|
|
344
|
+
function planAuthorizationFailure(input, decision) {
|
|
345
|
+
if (input.planAuthorization == null) return null;
|
|
346
|
+
if (input.plan == null || typeof input.unitId !== 'string') {
|
|
347
|
+
throw new TypeError('a plan-authorized dispatch needs its plan and unit id');
|
|
348
|
+
}
|
|
349
|
+
return authorizeDispatchUnit({
|
|
350
|
+
authorization: input.planAuthorization,
|
|
351
|
+
plan: input.plan,
|
|
352
|
+
unitId: input.unitId,
|
|
353
|
+
route: decision.bestExecutable ? receiptRoute(decision.bestExecutable) : null,
|
|
354
|
+
}).reason;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Handing back is not a dispatch: it proves nothing ran, and carries the intent on. */
|
|
358
|
+
function handoffOutcome(input, decision) {
|
|
359
|
+
const receipt = createDispatchReceipt({
|
|
360
|
+
executionId: input.executionId,
|
|
361
|
+
kind: 'handoff',
|
|
362
|
+
afk: input.afk,
|
|
363
|
+
handoff: { to: input.handoffTo ?? input.resolverInput.activeSurface },
|
|
364
|
+
reason: decisionReason(decision),
|
|
365
|
+
revisions: receiptRevisions(decision),
|
|
366
|
+
authorizationId: authorizationId(input, decision),
|
|
367
|
+
dispatchedAt: input.dispatchedAt,
|
|
368
|
+
});
|
|
369
|
+
return Object.freeze({
|
|
370
|
+
decision,
|
|
371
|
+
receipt,
|
|
372
|
+
dispatchResult: null,
|
|
373
|
+
handoff: Object.freeze({ to: receipt.handoff.to, intent: decision.intent ?? null }),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Whether the attested session default is a pair the policy authorized. The
|
|
379
|
+
* roster is matched under its own normalization rule, because one session
|
|
380
|
+
* reports its model in several forms while the roster spells out only one.
|
|
381
|
+
*/
|
|
382
|
+
function rosterAuthorizesPair(roster, attested) {
|
|
383
|
+
if (!Array.isArray(roster)) return false;
|
|
384
|
+
const model = normalizeRosterModelId(attested.model);
|
|
385
|
+
return roster.some((pair) => pair && typeof pair.model === 'string'
|
|
386
|
+
&& pair.model.trim() !== ''
|
|
387
|
+
&& normalizeRosterModelId(pair.model) === model
|
|
388
|
+
&& (pair.effort ?? null) === attested.effort);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function inheritFailure(input) {
|
|
392
|
+
if (input.afk === true) return 'AFK dispatch cannot inherit a session default';
|
|
393
|
+
const attested = input.sessionDefault?.attestation;
|
|
394
|
+
if (input.sessionDefault?.appliedRoute == null
|
|
395
|
+
|| typeof attested?.model !== 'string' || typeof attested?.effort !== 'string') {
|
|
396
|
+
return 'inherit requires an attested session-default pair';
|
|
397
|
+
}
|
|
398
|
+
return rosterAuthorizesPair(input.resolverInput?.policy?.roster, attested)
|
|
399
|
+
? null
|
|
400
|
+
: 'session-default pair is not in the effective roster';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function inheritOutcome(input, decision) {
|
|
404
|
+
const failure = inheritFailure(input);
|
|
405
|
+
if (failure) return blockedOnly(input, decision, failure);
|
|
406
|
+
try {
|
|
152
407
|
return Object.freeze({
|
|
153
408
|
decision,
|
|
154
|
-
receipt:
|
|
409
|
+
receipt: createDispatchReceipt({
|
|
410
|
+
executionId: input.executionId,
|
|
411
|
+
kind: 'inherited-dispatch',
|
|
412
|
+
afk: input.afk,
|
|
413
|
+
appliedRoute: receiptRoute(input.sessionDefault.appliedRoute),
|
|
414
|
+
enforcement: { model: 'session-default', effort: 'session-default' },
|
|
415
|
+
precedence: { model: 'session-default', effort: 'session-default' },
|
|
416
|
+
attestation: input.sessionDefault.attestation,
|
|
417
|
+
revisions: receiptRevisions(decision),
|
|
418
|
+
authorizationId: authorizationId(input, decision),
|
|
419
|
+
dispatchedAt: input.dispatchedAt,
|
|
420
|
+
}),
|
|
155
421
|
dispatchResult: null,
|
|
156
422
|
});
|
|
423
|
+
} catch {
|
|
424
|
+
// The readback the caller offered is no attestation; its own diagnostic
|
|
425
|
+
// never travels into a receipt.
|
|
426
|
+
return blockedOnly(input, decision, 'inherit requires an attested session-default pair');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The three paths a Route decision that cannot dispatch takes. */
|
|
431
|
+
function unreadyOutcome(input, decision) {
|
|
432
|
+
if (decision.status === 'handoff') return handoffOutcome(input, decision);
|
|
433
|
+
if (decision.status === 'inherit') return inheritOutcome(input, decision);
|
|
434
|
+
return blockedOnly(input, decision, decisionReason(decision));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function dispatchResolvedRoute(input) {
|
|
438
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
439
|
+
throw new TypeError('dispatch input must be an object');
|
|
440
|
+
}
|
|
441
|
+
if (!input.adapter || typeof input.adapter.prepare !== 'function') {
|
|
442
|
+
throw new TypeError('dispatch adapter must expose prepare');
|
|
443
|
+
}
|
|
444
|
+
const decision = resolveRoute(input.resolverInput);
|
|
445
|
+
const unauthorized = planAuthorizationFailure(input, decision);
|
|
446
|
+
if (unauthorized) return blockedOnly(input, decision, unauthorized);
|
|
447
|
+
if (decision.status !== 'ready') return unreadyOutcome(input, decision);
|
|
448
|
+
const journal = journalOptions(input, decision);
|
|
449
|
+
const requestedRoute = receiptRoute(decision.bestExecutable);
|
|
450
|
+
try {
|
|
451
|
+
return await performDispatch(input, decision, { journal, requestedRoute });
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return blockedOutcome(input, decision, { journal, requestedRoute, error });
|
|
157
454
|
}
|
|
158
455
|
}
|