@openwop/openwop-conformance 2.46.5 → 2.46.6
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/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-dispatch-fanout-waves.json +84 -0
- package/fixtures/conformance-dispatch-per-item-mismatch.json +83 -0
- package/fixtures/conformance-dispatch-per-item.json +86 -0
- package/fixtures.md +44 -0
- package/package.json +2 -2
- package/requirements.json +27 -3
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +9 -9
- package/src/lib/fan-out-witness.ts +233 -0
- package/src/lib/fixture-run-observer.ts +4 -2
- package/src/lib/run-graph-double.ts +6 -0
- package/src/scenarios/v2-agents-fan-out-waves.test.ts +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## [2.46.6] — 2026-10-13 — RFC 0243 G2: fan-out waves and per-item input
|
|
2
|
+
|
|
3
|
+
- **New `v2-agents-fan-out-waves`** (three ids, legs in `lib/fan-out-witness.ts`). The v1 twins drove a seam v2 does not carry; these run fixtures whose supervisor emits its `mockDispatchPlan`.
|
|
4
|
+
- `0243.max-fan-out-waves`, on `conformance-dispatch-fanout-waves`: three `conformance-delay` children under `maxConcurrency: 2` all complete, `dispatch.fanned-out` and `dispatch.joined` count three, and no more than `min(2, maxFanOut)` child windows overlap by more than 100 ms. Gated on `agents.fanOut` admitting `wait-all` with `collect`.
|
|
5
|
+
- `0243.per-item-input-gated`, on `conformance-dispatch-per-item`: at `agents.fanOut.perItemInput: true` each child receives its own `nextWorkerInputs` item; on any other dispatch host the dispatch node fails `validation_error` and no child is dispatched (a `400 validation_error` at `createRun` also satisfies it).
|
|
6
|
+
- `agents.dispatch-per-item-length-mismatch`, on `conformance-dispatch-per-item-mismatch`: one item for two children fails the dispatch node `validation_error`, no child, on every dispatch host.
|
|
7
|
+
- **What a host's next bundle shows:** the fixtures are opt-in through discovery `fixtures`, so a host that does not list them records all three `inapplicable`. A dispatch host that lists the per-item fixtures and drops `nextWorkerInputs` records `executed-fail`.
|
|
8
|
+
- `ObservedRun` gains an optional `createBody`; the run-graph double can refuse a create and add snapshot members.
|
|
9
|
+
|
|
1
10
|
## [2.46.5] — 2026-10-13 — RFC 0243 Accepted; a fan-out prediction corrected
|
|
2
11
|
|
|
3
12
|
- **No assertion changes.** This cycle opens because RFC 0243's gap G1 closes, which changes the packed `spec/v1/gaps.json`.
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ Each scenario file records its disposition and assertion count in `<report-dir>/
|
|
|
137
137
|
|
|
138
138
|
## What's Covered
|
|
139
139
|
|
|
140
|
-
The current suite has
|
|
140
|
+
The current suite has 657 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 217 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
|
|
141
141
|
|
|
142
142
|
The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
|
|
143
143
|
|
|
@@ -166,7 +166,7 @@ Three kinds of scenario share the tree:
|
|
|
166
166
|
- **Host** checks drive a live host over HTTP.
|
|
167
167
|
- **Gated** checks run only when the host advertises the family, profile or fixture they need, and otherwise record `inapplicable` or `blocked` with a reason. `--require-behavior` turns an advertised-but-unwitnessable behaviour into a failure.
|
|
168
168
|
|
|
169
|
-
Current source tree:
|
|
169
|
+
Current source tree: 657 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
|
|
170
170
|
|
|
171
171
|
## How the 2.x suite is built
|
|
172
172
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conformance-dispatch-fanout-waves",
|
|
3
|
+
"name": "Conformance: Dispatch Fan-Out Waves",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"description": "A parallel fan-out of three conformance-delay children with maxConcurrency 2: at most two children run at once, and none is dropped (RFC 0243 \u00a7A.3).",
|
|
6
|
+
"nodes": [
|
|
7
|
+
{
|
|
8
|
+
"id": "supervisor",
|
|
9
|
+
"typeId": "core.orchestrator.supervisor",
|
|
10
|
+
"name": "Supervisor",
|
|
11
|
+
"position": {
|
|
12
|
+
"x": 0,
|
|
13
|
+
"y": 0
|
|
14
|
+
},
|
|
15
|
+
"config": {
|
|
16
|
+
"mockDispatchPlan": [
|
|
17
|
+
{
|
|
18
|
+
"kind": "next-worker",
|
|
19
|
+
"nextWorkerIds": [
|
|
20
|
+
"conformance-delay",
|
|
21
|
+
"conformance-delay",
|
|
22
|
+
"conformance-delay"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"kind": "terminate",
|
|
27
|
+
"reason": "goal-reached"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"inputs": {}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "dispatch",
|
|
35
|
+
"typeId": "core.dispatch",
|
|
36
|
+
"name": "Dispatch",
|
|
37
|
+
"position": {
|
|
38
|
+
"x": 200,
|
|
39
|
+
"y": 0
|
|
40
|
+
},
|
|
41
|
+
"config": {
|
|
42
|
+
"workerDispatchModel": "child-run",
|
|
43
|
+
"fanOutPolicy": "parallel",
|
|
44
|
+
"maxConcurrency": 2,
|
|
45
|
+
"joinPolicy": {
|
|
46
|
+
"mode": "wait-all",
|
|
47
|
+
"onChildFailure": "collect"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"inputs": {}
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"edges": [
|
|
54
|
+
{
|
|
55
|
+
"id": "e1",
|
|
56
|
+
"sourceNodeId": "supervisor",
|
|
57
|
+
"targetNodeId": "dispatch"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "e2",
|
|
61
|
+
"sourceNodeId": "dispatch",
|
|
62
|
+
"targetNodeId": "supervisor"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"triggers": [
|
|
66
|
+
{
|
|
67
|
+
"id": "manual",
|
|
68
|
+
"type": "manual",
|
|
69
|
+
"enabled": true
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"variables": [],
|
|
73
|
+
"metadata": {
|
|
74
|
+
"tags": [
|
|
75
|
+
"conformance",
|
|
76
|
+
"dispatch",
|
|
77
|
+
"fan-out"
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
"settings": {
|
|
81
|
+
"timeout": 60000,
|
|
82
|
+
"maxRetries": 0
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conformance-dispatch-per-item-mismatch",
|
|
3
|
+
"name": "Conformance: Dispatch Per-Item Length Mismatch",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"description": "A next-worker decision whose nextWorkerInputs is shorter than nextWorkerIds. Every host fails the dispatch node with validation_error and dispatches no child (RFC 0126, orchestrator-decision.schema.json nextWorkerInputs).",
|
|
6
|
+
"nodes": [
|
|
7
|
+
{
|
|
8
|
+
"id": "supervisor",
|
|
9
|
+
"typeId": "core.orchestrator.supervisor",
|
|
10
|
+
"name": "Supervisor",
|
|
11
|
+
"position": {
|
|
12
|
+
"x": 0,
|
|
13
|
+
"y": 0
|
|
14
|
+
},
|
|
15
|
+
"config": {
|
|
16
|
+
"mockDispatchPlan": [
|
|
17
|
+
{
|
|
18
|
+
"kind": "next-worker",
|
|
19
|
+
"nextWorkerIds": [
|
|
20
|
+
"conformance-dispatch-input-mapping-child",
|
|
21
|
+
"conformance-dispatch-input-mapping-child"
|
|
22
|
+
],
|
|
23
|
+
"nextWorkerInputs": [
|
|
24
|
+
{
|
|
25
|
+
"childGreeting": "item-a"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"kind": "terminate",
|
|
31
|
+
"reason": "goal-reached"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"inputs": {}
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "dispatch",
|
|
39
|
+
"typeId": "core.dispatch",
|
|
40
|
+
"name": "Dispatch",
|
|
41
|
+
"position": {
|
|
42
|
+
"x": 200,
|
|
43
|
+
"y": 0
|
|
44
|
+
},
|
|
45
|
+
"config": {
|
|
46
|
+
"workerDispatchModel": "child-run",
|
|
47
|
+
"fanOutPolicy": "sequential"
|
|
48
|
+
},
|
|
49
|
+
"inputs": {}
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"edges": [
|
|
53
|
+
{
|
|
54
|
+
"id": "e1",
|
|
55
|
+
"sourceNodeId": "supervisor",
|
|
56
|
+
"targetNodeId": "dispatch"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "e2",
|
|
60
|
+
"sourceNodeId": "dispatch",
|
|
61
|
+
"targetNodeId": "supervisor"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"triggers": [
|
|
65
|
+
{
|
|
66
|
+
"id": "manual",
|
|
67
|
+
"type": "manual",
|
|
68
|
+
"enabled": true
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"variables": [],
|
|
72
|
+
"metadata": {
|
|
73
|
+
"tags": [
|
|
74
|
+
"conformance",
|
|
75
|
+
"dispatch",
|
|
76
|
+
"per-item-input"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"settings": {
|
|
80
|
+
"timeout": 60000,
|
|
81
|
+
"maxRetries": 0
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conformance-dispatch-per-item",
|
|
3
|
+
"name": "Conformance: Dispatch Per-Item Input",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"description": "One next-worker decision dispatching the same child twice with distinct nextWorkerInputs. A host advertising agents.fanOut.perItemInput true projects each item into its child; any other host fails the dispatch node with validation_error and dispatches no child (RFC 0243 \u00a7A.4).",
|
|
6
|
+
"nodes": [
|
|
7
|
+
{
|
|
8
|
+
"id": "supervisor",
|
|
9
|
+
"typeId": "core.orchestrator.supervisor",
|
|
10
|
+
"name": "Supervisor",
|
|
11
|
+
"position": {
|
|
12
|
+
"x": 0,
|
|
13
|
+
"y": 0
|
|
14
|
+
},
|
|
15
|
+
"config": {
|
|
16
|
+
"mockDispatchPlan": [
|
|
17
|
+
{
|
|
18
|
+
"kind": "next-worker",
|
|
19
|
+
"nextWorkerIds": [
|
|
20
|
+
"conformance-dispatch-input-mapping-child",
|
|
21
|
+
"conformance-dispatch-input-mapping-child"
|
|
22
|
+
],
|
|
23
|
+
"nextWorkerInputs": [
|
|
24
|
+
{
|
|
25
|
+
"childGreeting": "item-a"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"childGreeting": "item-b"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"kind": "terminate",
|
|
34
|
+
"reason": "goal-reached"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"inputs": {}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "dispatch",
|
|
42
|
+
"typeId": "core.dispatch",
|
|
43
|
+
"name": "Dispatch",
|
|
44
|
+
"position": {
|
|
45
|
+
"x": 200,
|
|
46
|
+
"y": 0
|
|
47
|
+
},
|
|
48
|
+
"config": {
|
|
49
|
+
"workerDispatchModel": "child-run",
|
|
50
|
+
"fanOutPolicy": "sequential"
|
|
51
|
+
},
|
|
52
|
+
"inputs": {}
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"edges": [
|
|
56
|
+
{
|
|
57
|
+
"id": "e1",
|
|
58
|
+
"sourceNodeId": "supervisor",
|
|
59
|
+
"targetNodeId": "dispatch"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "e2",
|
|
63
|
+
"sourceNodeId": "dispatch",
|
|
64
|
+
"targetNodeId": "supervisor"
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"triggers": [
|
|
68
|
+
{
|
|
69
|
+
"id": "manual",
|
|
70
|
+
"type": "manual",
|
|
71
|
+
"enabled": true
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"variables": [],
|
|
75
|
+
"metadata": {
|
|
76
|
+
"tags": [
|
|
77
|
+
"conformance",
|
|
78
|
+
"dispatch",
|
|
79
|
+
"per-item-input"
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
"settings": {
|
|
83
|
+
"timeout": 60000,
|
|
84
|
+
"maxRetries": 0
|
|
85
|
+
}
|
|
86
|
+
}
|
package/fixtures.md
CHANGED
|
@@ -69,6 +69,9 @@ All fixtures MUST advertise:
|
|
|
69
69
|
| Prompt All Four Kinds | `conformance-prompt-all-four-kinds` | RFC 0027 §A four-kind dispatch coverage with a MULTI-ENTRY `fewShotPromptRefs[]` array. Single `mock-ai` node with one ref per singular-kind slot (`systemPromptRef`, `userPromptRef`, `schemaHintPromptRef`) + two distinct templateIds in `fewShotPromptRefs[]`; host MUST emit 5 `agent.promptResolved` events (one per slot) AND 5 `prompt.composed` events. Multi-entry few-shot is the regression pin for `fewShotPromptRefs[slotIndex]` per-index resolution — a host that hard-codes `[0]` would emit the same template twice in the few-shot events and fail the per-templateId assertion. Capability-gated on `capabilities.prompts.supported`. | `completed` | ≤ 10s |
|
|
70
70
|
| Dispatch Loop | `conformance-dispatch-loop` | Verifies `core.dispatch` loop mechanism | `completed` | ≤ 30s |
|
|
71
71
|
| Dispatch Fan-Out Race | `conformance-dispatch-fanout-race` | RFC 0243 §A.2 — a `core.dispatch` node pinning `fanOutPolicy: 'parallel'` with a `race` join. A host that does not admit it refuses it at load, or at `createRun` with `400 validation_error` and `details.requiredCapability` (`v2-agents-fan-out`) | refused; no run | ≤ 5s |
|
|
72
|
+
| Dispatch Fan-Out Waves | `conformance-dispatch-fanout-waves` | RFC 0243 §A.3 — a parallel `core.dispatch` (`maxConcurrency: 2`, `wait-all`/`collect`) whose supervisor's `mockDispatchPlan` names three `conformance-delay` children. At most `min(2, maxFanOut)` run at once and none is dropped. Gated on `agents.fanOut` admitting `wait-all` with `collect`. | `completed` | ≤ 30s |
|
|
73
|
+
| Dispatch Per-Item Input | `conformance-dispatch-per-item` | RFC 0243 §A.4 — a sequential `core.dispatch` whose decision dispatches `conformance-dispatch-input-mapping-child` twice with `nextWorkerInputs` `item-a`, `item-b`. At `agents.fanOut.perItemInput: true` each child receives its item; otherwise the dispatch node fails `validation_error` and no child is dispatched. | `completed`, or `failed` without `perItemInput` | ≤ 30s |
|
|
74
|
+
| Dispatch Per-Item Length Mismatch | `conformance-dispatch-per-item-mismatch` | `orchestrator-decision.schema.json` `nextWorkerInputs` — one item for two children. Every dispatch host fails the dispatch node `validation_error` and dispatches no child. | `failed` | ≤ 30s |
|
|
72
75
|
| Interrupt — Quorum | `conformance-interrupt-quorum` | Verifies `openwop-interrupt-quorum` profile (multi-approver, majority rejection) | `completed` after 3 accepts, `failed` after quorum reject (major 2: `approval_rejected`) | unbounded (suspends) |
|
|
73
76
|
| Interrupt — External Event | `conformance-interrupt-external-event` | Verifies `openwop-interrupt-external-event` profile (correlation-matched callback) | `completed` after matching POST, `failed` on timeout | ≤ 60s (timeoutMs configured) |
|
|
74
77
|
| Interrupt — Auth Required | `conformance-interrupt-auth-required` | Verifies `openwop-interrupt-auth-required` profile (bearer-token resume only) | `completed` after bearer resolve | unbounded (suspends) |
|
|
@@ -396,6 +399,47 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
|
|
|
396
399
|
---
|
|
397
400
|
|
|
398
401
|
|
|
402
|
+
### `conformance-dispatch-fanout-waves`
|
|
403
|
+
|
|
404
|
+
- **Purpose**: observe the RFC 0243 §A.3 breadth rule: the effective concurrency is `min(config.maxConcurrency, maxFanOut)`, children beyond it run in waves, and none is dropped (`spec/v2/facets/agents.schema.json` §fanOut).
|
|
405
|
+
- **Topology**: `core.orchestrator.supervisor` ⇄ `core.dispatch`. The supervisor's `config.mockDispatchPlan` is `next-worker` with `nextWorkerIds` three times `conformance-delay`, then `terminate`. The dispatch node is `{ workerDispatchModel: "child-run", fanOutPolicy: "parallel", maxConcurrency: 2, joinPolicy: { mode: "wait-all", onChildFailure: "collect" } }`.
|
|
406
|
+
- **Inputs**: none. Each child sleeps its default `delayMs` (1000).
|
|
407
|
+
- **Conformance test driver**:
|
|
408
|
+
1. Skip unless `agents.dispatch` is `true`, `agents.fanOut` lists `wait-all` and `collect`, and discovery `fixtures` lists this fixture and `conformance-delay`.
|
|
409
|
+
2. POST `/runs` with `{workflowId: "conformance-dispatch-fanout-waves"}`; poll until terminal.
|
|
410
|
+
3. **Assert** the run completed; one `dispatch.fanned-out` with `childCount` 3 and any `maxConcurrency` at most `min(2, maxFanOut)`; one `dispatch.joined` with `completedCount` 3, `failedCount` 0 and a `mergeOrder` of 3.
|
|
411
|
+
4. Read each child (`node.dispatched`, else `mergeOrder`). **Assert** three children, each `completed`, and that no more than `min(2, maxFanOut)` of their `startedAt`..`completedAt` windows overlap by more than 100 ms.
|
|
412
|
+
- **Terminal status**: `completed`.
|
|
413
|
+
- **Server prerequisites**: the supervisor emits the `mockDispatchPlan` verbatim, as for `conformance-dispatch-cross-worker-handoff`.
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
### `conformance-dispatch-per-item`
|
|
418
|
+
|
|
419
|
+
- **Purpose**: observe the RFC 0243 §A.4 gate on `nextWorkerInputs`: honored at `agents.fanOut.perItemInput: true`, failed closed otherwise.
|
|
420
|
+
- **Topology**: `core.orchestrator.supervisor` ⇄ `core.dispatch` (`fanOutPolicy: "sequential"`). The `mockDispatchPlan` is `next-worker` with `nextWorkerIds` twice `conformance-dispatch-input-mapping-child` and `nextWorkerInputs` `[{childGreeting: "item-a"}, {childGreeting: "item-b"}]`, then `terminate`.
|
|
421
|
+
- **Inputs**: none.
|
|
422
|
+
- **Conformance test driver**:
|
|
423
|
+
1. Skip unless `agents.dispatch` is `true` and discovery `fixtures` lists this fixture and `conformance-dispatch-input-mapping-child`.
|
|
424
|
+
2. POST `/runs`; poll until terminal; read each child found by `node.dispatched`.
|
|
425
|
+
3. At `perItemInput: true`, **assert** the run completed, two children were dispatched, and their `inputs.childGreeting` are `item-a` and `item-b`.
|
|
426
|
+
4. Otherwise, **assert** the dispatch node's `node.failed` carries `error.code: "validation_error"`, no child was dispatched, and the run ended `failed`. A `400 validation_error` at `createRun` also satisfies this step.
|
|
427
|
+
- **Terminal status**: `completed` at `perItemInput: true`; otherwise `failed`.
|
|
428
|
+
- **Server prerequisites**: as `conformance-dispatch-fanout-waves`.
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
### `conformance-dispatch-per-item-mismatch`
|
|
433
|
+
|
|
434
|
+
- **Purpose**: observe the length rule on `nextWorkerInputs` (`orchestrator-decision.schema.json`): a length that differs from `nextWorkerIds` fails the dispatch node with `validation_error` and dispatches no child.
|
|
435
|
+
- **Topology**: as `conformance-dispatch-per-item`, with one item for two children.
|
|
436
|
+
- **Inputs**: none.
|
|
437
|
+
- **Conformance test driver**: steps 1, 2 and 4 of `conformance-dispatch-per-item`, on every dispatch host.
|
|
438
|
+
- **Terminal status**: `failed`.
|
|
439
|
+
- **Server prerequisites**: as `conformance-dispatch-fanout-waves`.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
399
443
|
### `conformance-dispatch-fanout-race`
|
|
400
444
|
|
|
401
445
|
- **Purpose**: observe the RFC 0243 §A.2 refusal of a parallel fan-out the host does not admit (`spec/v2/facets/agents.schema.json` §fanOut).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.46.
|
|
3
|
+
"version": "2.46.6",
|
|
4
4
|
"description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.46.
|
|
59
|
+
"@openwop/spec-artifacts": "2.46.6"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 745,
|
|
6
|
+
"tests": 2800,
|
|
7
|
+
"withStableId": 2800,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
9
|
"explicitIds": 2653
|
|
10
10
|
},
|
|
@@ -28106,6 +28106,30 @@
|
|
|
28106
28106
|
}
|
|
28107
28107
|
]
|
|
28108
28108
|
},
|
|
28109
|
+
{
|
|
28110
|
+
"id": "openwop.it.v2-agents-fan-out-waves.children-beyond-the-effective-ceiling-are-dispatched-in-waves-and-none-is-droppe",
|
|
28111
|
+
"file": "v2-agents-fan-out-waves.test.ts",
|
|
28112
|
+
"line": 64,
|
|
28113
|
+
"title": "children beyond the effective ceiling are dispatched in waves, and none is dropped",
|
|
28114
|
+
"explicitId": null,
|
|
28115
|
+
"citations": []
|
|
28116
|
+
},
|
|
28117
|
+
{
|
|
28118
|
+
"id": "openwop.it.v2-agents-fan-out-waves.nextworkerinputs-reaches-each-child-at-periteminput-true-and-fails-closed-withou",
|
|
28119
|
+
"file": "v2-agents-fan-out-waves.test.ts",
|
|
28120
|
+
"line": 72,
|
|
28121
|
+
"title": "nextWorkerInputs reaches each child at perItemInput: true, and fails closed without it",
|
|
28122
|
+
"explicitId": null,
|
|
28123
|
+
"citations": []
|
|
28124
|
+
},
|
|
28125
|
+
{
|
|
28126
|
+
"id": "openwop.it.v2-agents-fan-out-waves.a-nextworkerinputs-length-that-differs-from-nextworkerids-fails-the-dispatch-nod",
|
|
28127
|
+
"file": "v2-agents-fan-out-waves.test.ts",
|
|
28128
|
+
"line": 80,
|
|
28129
|
+
"title": "a nextWorkerInputs length that differs from nextWorkerIds fails the dispatch node and dispatches no child",
|
|
28130
|
+
"explicitId": null,
|
|
28131
|
+
"citations": []
|
|
28132
|
+
},
|
|
28109
28133
|
{
|
|
28110
28134
|
"id": "openwop.it.v2-agents-fan-out.a-host-advertising-agents-fanout-advertises-agents-dispatch-true",
|
|
28111
28135
|
"file": "v2-agents-fan-out.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 657,
|
|
5
5
|
"v1": 455,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 217
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1302,6 +1302,9 @@
|
|
|
1302
1302
|
"v2-agent-runtime-advertisement.test.ts": [
|
|
1303
1303
|
2
|
|
1304
1304
|
],
|
|
1305
|
+
"v2-agents-fan-out-waves.test.ts": [
|
|
1306
|
+
2
|
|
1307
|
+
],
|
|
1305
1308
|
"v2-agents-fan-out.test.ts": [
|
|
1306
1309
|
2
|
|
1307
1310
|
],
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.46.
|
|
5
|
-
"corpusTag": "v2.46.
|
|
4
|
+
"version": "2.46.6",
|
|
5
|
+
"corpusTag": "v2.46.6",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "48ca51cc211127aed939340f5a39232d9f938037081d88ce9107f0929982d3c5",
|
|
13
|
+
"api/v2/asyncapi.yaml": "4a5c1f6c99d08e41883637b2ea4a663687cf1d916d879cc490e6836ed7a0051f",
|
|
14
|
+
"api/v2/openapi.yaml": "c4ca7bae3a66460097646432792d54146891b433f7dab62e4dcceb857738ae4a",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
209
209
|
"spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
|
|
210
210
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
211
|
-
"spec/v1/core-standard-manifest.json": "
|
|
211
|
+
"spec/v1/core-standard-manifest.json": "4375f41b350294f0cc2a045176389f459452af3708a1dd408df49293d5a823bd",
|
|
212
212
|
"spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
|
|
213
213
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
214
214
|
"spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
|
|
@@ -313,9 +313,9 @@
|
|
|
313
313
|
"spec/v2/path-manifest.json": "7b5679a4dad98c80d855361d88a98c8a4e045af15e310862a05b285b815dc9fc",
|
|
314
314
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
315
315
|
"spec/v2/profiles.json": "b382b261c7844798575f7c1b37c934f0da19846016a2337d883d4f5ef6e05479",
|
|
316
|
-
"spec/v2/release.json": "
|
|
316
|
+
"spec/v2/release.json": "84cecdf093e65301c2c4516a2665e9806d15ea82c50b4368888b918214254bb0",
|
|
317
317
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
318
|
-
"spec/v2/surface-baseline.json": "
|
|
318
|
+
"spec/v2/surface-baseline.json": "78eff58bd5ea5ab23968f840d886c3e78c280ff4f2ca235c792625446095b60a"
|
|
319
319
|
},
|
|
320
|
-
"corpusCommit": "
|
|
320
|
+
"corpusCommit": "213b2eafb1ed4cfaa71bcb088fb86f520b8688bf"
|
|
321
321
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0243 G2: the `agents.fanOut` breadth and per-item witnesses, fixture
|
|
3
|
+
* runs through `POST /runs`, `events/poll` and `GET /runs/{runId}`. No seam:
|
|
4
|
+
* each fixture's supervisor carries a `mockDispatchPlan`, so the decision is
|
|
5
|
+
* the fixture's, not a model's.
|
|
6
|
+
*
|
|
7
|
+
* Waves (`spec/v2/facets/agents.schema.json` §fanOut `maxFanOut`: "the
|
|
8
|
+
* effective concurrency is `min(config.maxConcurrency ?? ∞, maxFanOut ?? ∞)`,
|
|
9
|
+
* children beyond it are dispatched in waves and none is dropped"), on
|
|
10
|
+
* `conformance-dispatch-fanout-waves` (three `conformance-delay` children,
|
|
11
|
+
* `maxConcurrency: 2`):
|
|
12
|
+
* the parallel path records one `dispatch.fanned-out` (`childCount` 3, any
|
|
13
|
+
* `maxConcurrency` within the effective ceiling) and one `dispatch.joined`
|
|
14
|
+
* (3 completed, none failed, `mergeOrder` of 3); three child runs complete;
|
|
15
|
+
* and no more than the ceiling of them are in flight at once, judged from
|
|
16
|
+
* each child's `startedAt`..`completedAt`.
|
|
17
|
+
*
|
|
18
|
+
* Per-item input (the facet's `perItemInput`; `orchestrator-decision.schema.json`
|
|
19
|
+
* `nextWorkerInputs`), on `conformance-dispatch-per-item` (one child workflow
|
|
20
|
+
* twice, items `item-a` and `item-b`):
|
|
21
|
+
* honored at `perItemInput: true`, two children are dispatched and
|
|
22
|
+
* their `inputs.childGreeting` are the two items;
|
|
23
|
+
* fail closed otherwise, the dispatch node fails `validation_error` and no
|
|
24
|
+
* child is dispatched.
|
|
25
|
+
* Length mismatch (`nextWorkerInputs`: "`nextWorkerInputs.length` MUST equal
|
|
26
|
+
* `nextWorkerIds.length`"), on `conformance-dispatch-per-item-mismatch`: on
|
|
27
|
+
* every dispatch host, the dispatch node fails `validation_error` and no child
|
|
28
|
+
* is dispatched.
|
|
29
|
+
*
|
|
30
|
+
* Fail closed is also read as satisfied by a `400 validation_error` at
|
|
31
|
+
* `createRun` with no run: either way no child exists, which is the hazard.
|
|
32
|
+
*
|
|
33
|
+
* The drivers observe and assert nothing; the judges are pure, proven in
|
|
34
|
+
* `fan-out-witness.test.ts`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { isFixtureAdvertised } from './fixtures.js';
|
|
38
|
+
import type { MajorProfile } from './major-profile.js';
|
|
39
|
+
import {
|
|
40
|
+
finding, isRecord, observed, observeRun, readRun, runCompleted, skip,
|
|
41
|
+
type Ev, type Finding, type ObservedRun, type ObserveOpts, type Outcome, type RunRead, type Skip,
|
|
42
|
+
} from './fixture-run-observer.js';
|
|
43
|
+
|
|
44
|
+
export const WAVES_FIXTURE = 'conformance-dispatch-fanout-waves';
|
|
45
|
+
export const WAVES_CHILD = 'conformance-delay';
|
|
46
|
+
export const PER_ITEM_FIXTURE = 'conformance-dispatch-per-item';
|
|
47
|
+
export const PER_ITEM_MISMATCH_FIXTURE = 'conformance-dispatch-per-item-mismatch';
|
|
48
|
+
export const PER_ITEM_CHILD = 'conformance-dispatch-input-mapping-child';
|
|
49
|
+
export const PER_ITEM_VALUES = ['item-a', 'item-b'] as const;
|
|
50
|
+
export const DISPATCH_NODE = 'dispatch';
|
|
51
|
+
/** The fixture's `maxConcurrency`. */
|
|
52
|
+
export const WAVES_MAX_CONCURRENCY = 2;
|
|
53
|
+
export const WAVES_CHILDREN = 3;
|
|
54
|
+
/**
|
|
55
|
+
* Two child windows that touch within this many milliseconds are not counted as
|
|
56
|
+
* overlapping: a host stamps a child's `completedAt` and the next child's
|
|
57
|
+
* `startedAt` from separate clocks reads. A real breach overlaps by a whole
|
|
58
|
+
* `conformance-delay` (1000 ms by default).
|
|
59
|
+
*/
|
|
60
|
+
export const OVERLAP_TOLERANCE_MS = 100;
|
|
61
|
+
|
|
62
|
+
const DOC_FANOUT = 'agents.schema.json §fanOut';
|
|
63
|
+
const DOC_ITEMS = 'orchestrator-decision.schema.json nextWorkerInputs';
|
|
64
|
+
|
|
65
|
+
export interface FanOutObservation {
|
|
66
|
+
readonly run: ObservedRun;
|
|
67
|
+
/** Child run ids in dispatch order, from `node.dispatched` (else the join's `mergeOrder`). */
|
|
68
|
+
readonly childRunIds: readonly string[];
|
|
69
|
+
readonly children: readonly RunRead[];
|
|
70
|
+
/** `fannedType` / `joinedType` / `dispatchedType`: this major's event names. */
|
|
71
|
+
readonly types: { readonly fanned: string; readonly joined: string; readonly dispatched: string };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WavesObservation extends FanOutObservation {
|
|
75
|
+
/** `min(maxConcurrency, maxFanOut ?? ∞)`. */
|
|
76
|
+
readonly ceiling: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PerItemObservation extends FanOutObservation {
|
|
80
|
+
/** The host advertises `agents.fanOut.perItemInput: true`. */
|
|
81
|
+
readonly perItemInput: boolean;
|
|
82
|
+
/** The `createRun` body, for a refusal there. */
|
|
83
|
+
readonly createBody: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function eventTypes(profile: MajorProfile): Skip | FanOutObservation['types'] {
|
|
87
|
+
const fanned = profile.eventType('core.dispatch.fanOut');
|
|
88
|
+
const joined = profile.eventType('core.dispatch.join');
|
|
89
|
+
if (fanned === undefined || joined === undefined) return skip('blocked', 'the event codemap is not on disk in this layout — the suite will not guess an event name');
|
|
90
|
+
return { fanned, joined, dispatched: profile.eventType('node.dispatched') ?? 'node.dispatched' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `agents.dispatch: true`, and the fixtures. */
|
|
94
|
+
function dispatchGate(profile: MajorProfile, doc: unknown, fixtures: readonly string[]): Skip | { readonly agents: Record<string, unknown> } {
|
|
95
|
+
const agents = profile.family(doc, 'agents');
|
|
96
|
+
if (agents === null || agents['dispatch'] !== true) return skip('inapplicable', 'the host does not advertise agents.dispatch: true, so core.dispatch does not bind it');
|
|
97
|
+
for (const id of fixtures) if (!isFixtureAdvertised(id)) return skip('inapplicable', `fixture ${id} is not advertised — the fixture is the opt-in`);
|
|
98
|
+
return { agents };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `agents.fanOut` admitting a `wait-all` join with `collect`, and the fixtures. */
|
|
102
|
+
export function wavesGate(profile: MajorProfile, doc: unknown): Skip | { ceiling: number } {
|
|
103
|
+
const g = dispatchGate(profile, doc, [WAVES_FIXTURE, WAVES_CHILD]);
|
|
104
|
+
if ('kind' in g) return g;
|
|
105
|
+
const f = g.agents['fanOut'];
|
|
106
|
+
if (!isRecord(f)) return skip('inapplicable', 'the host does not advertise agents.fanOut, so it runs no parallel fan-out (the race fixture witnesses its refusal)');
|
|
107
|
+
const has = (k: string, v: string): boolean => Array.isArray(f[k]) && (f[k] as unknown[]).includes(v);
|
|
108
|
+
if (!has('joinModes', 'wait-all') || !has('onChildFailureModes', 'collect')) return skip('inapplicable', 'agents.fanOut does not admit a wait-all join with collect, which this fixture pins');
|
|
109
|
+
const max = typeof f['maxFanOut'] === 'number' && f['maxFanOut'] >= 1 ? f['maxFanOut'] : Infinity;
|
|
110
|
+
return { ceiling: Math.min(WAVES_MAX_CONCURRENCY, max) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `agents.dispatch: true` and the per-item fixture (or the mismatch fixture) plus its child. */
|
|
114
|
+
export function perItemGate(profile: MajorProfile, doc: unknown, fixture: string): Skip | { perItemInput: boolean } {
|
|
115
|
+
const g = dispatchGate(profile, doc, [fixture, PER_ITEM_CHILD]);
|
|
116
|
+
if ('kind' in g) return g;
|
|
117
|
+
const f = g.agents['fanOut'];
|
|
118
|
+
return { perItemInput: isRecord(f) && f['perItemInput'] === true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The dispatched children of one workflow, in log order: `node.dispatched`, else the join's `mergeOrder`. */
|
|
122
|
+
export function locateChildren(events: readonly Ev[], types: FanOutObservation['types'], childWorkflowId: string): string[] {
|
|
123
|
+
const ids = events
|
|
124
|
+
.filter((e) => e.type === types.dispatched && e.payload['childWorkflowId'] === childWorkflowId && typeof e.payload['childRunId'] === 'string')
|
|
125
|
+
.map((e) => e.payload['childRunId'] as string);
|
|
126
|
+
if (ids.length > 0) return [...new Set(ids)];
|
|
127
|
+
const join = events.find((e) => e.type === types.joined);
|
|
128
|
+
const mo = join?.payload['mergeOrder'];
|
|
129
|
+
return Array.isArray(mo) ? [...new Set(mo.filter((x): x is string => typeof x === 'string'))] : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function follow(profile: MajorProfile, workflowId: string, child: string, types: FanOutObservation['types'], opts: ObserveOpts): Promise<FanOutObservation & { createBody: unknown }> {
|
|
133
|
+
const run = await observeRun(profile, workflowId, { timeoutMs: 45_000, ...opts });
|
|
134
|
+
const childRunIds = locateChildren(run.events, types, child);
|
|
135
|
+
const children = await Promise.all(childRunIds.map((id) => readRun(profile, id)));
|
|
136
|
+
return { run, childRunIds, children, types, createBody: run.createBody };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Run the waves fixture and read its children. Asserts nothing. */
|
|
140
|
+
export async function driveWaves(profile: MajorProfile, doc: unknown, opts: ObserveOpts = {}): Promise<Skip | WavesObservation> {
|
|
141
|
+
const g = wavesGate(profile, doc);
|
|
142
|
+
if ('kind' in g) return g;
|
|
143
|
+
const types = eventTypes(profile);
|
|
144
|
+
if ('kind' in types) return types;
|
|
145
|
+
return { ...(await follow(profile, WAVES_FIXTURE, WAVES_CHILD, types, opts)), ceiling: g.ceiling };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Run a per-item fixture and read its children. Asserts nothing. */
|
|
149
|
+
export async function drivePerItem(profile: MajorProfile, doc: unknown, fixture: string, opts: ObserveOpts = {}): Promise<Skip | PerItemObservation> {
|
|
150
|
+
const g = perItemGate(profile, doc, fixture);
|
|
151
|
+
if ('kind' in g) return g;
|
|
152
|
+
const types = eventTypes(profile);
|
|
153
|
+
if ('kind' in types) return types;
|
|
154
|
+
return { ...(await follow(profile, fixture, PER_ITEM_CHILD, types, opts)), perItemInput: g.perItemInput };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const ofType = (o: FanOutObservation, t: string): Ev[] => o.run.events.filter((e) => e.type === t && (e.nodeId === undefined || e.nodeId === DISPATCH_NODE));
|
|
158
|
+
const time = (v: unknown): number | undefined => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? Date.parse(v) : undefined);
|
|
159
|
+
|
|
160
|
+
/** The largest number of `[start, end]` windows in flight at once, ignoring touches within `tolerance`. Pure. */
|
|
161
|
+
export function maxInFlight(windows: ReadonlyArray<readonly [number, number]>, tolerance = OVERLAP_TOLERANCE_MS): number {
|
|
162
|
+
if (windows.length === 0) return 0;
|
|
163
|
+
// A window counts from `tolerance` after its start; one shorter than that cannot overlap anything.
|
|
164
|
+
const points = windows.filter(([s, e]) => s + tolerance < e).flatMap(([s, e]) => [[s + tolerance, 1], [e, -1]] as const);
|
|
165
|
+
// At the same instant an end closes before a start opens, so touching windows never overlap.
|
|
166
|
+
points.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
167
|
+
let cur = 0;
|
|
168
|
+
let best = 1;
|
|
169
|
+
for (const [, d] of points) { cur += d; best = Math.max(best, cur); }
|
|
170
|
+
return best;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Leg: waves within the ceiling, none dropped. Pure. */
|
|
174
|
+
export function judgeWaves(o: WavesObservation): Outcome {
|
|
175
|
+
const fanned = ofType(o, o.types.fanned);
|
|
176
|
+
const joined = ofType(o, o.types.joined);
|
|
177
|
+
const f = fanned[0]?.payload ?? {};
|
|
178
|
+
const j = joined[0]?.payload ?? {};
|
|
179
|
+
const mc = f['maxConcurrency'];
|
|
180
|
+
const windows = o.children.map((c) => [time(c.snapshot['startedAt']), time(c.snapshot['completedAt'])] as const);
|
|
181
|
+
const timed = windows.every(([s, e]) => s !== undefined && e !== undefined);
|
|
182
|
+
const findings: Finding[] = [
|
|
183
|
+
...runCompleted(o.run),
|
|
184
|
+
finding(fanned.length === 1, `${DOC_FANOUT} (run-event-payloads dispatchFanOut)`, `a parallel dispatch MUST record one ${o.types.fanned} (got ${fanned.length})`),
|
|
185
|
+
finding(f['childCount'] === WAVES_CHILDREN, `${DOC_FANOUT} (none is dropped)`, `${o.types.fanned}.childCount MUST be ${WAVES_CHILDREN}, every child the decision named (got ${JSON.stringify(f['childCount'])})`),
|
|
186
|
+
finding(mc === undefined || (typeof mc === 'number' && mc <= o.ceiling), `${DOC_FANOUT} (effective concurrency is min(maxConcurrency, maxFanOut))`, `${o.types.fanned}.maxConcurrency MUST NOT exceed ${o.ceiling} (got ${JSON.stringify(mc)})`),
|
|
187
|
+
finding(joined.length === 1, `${DOC_FANOUT} (run-event-payloads dispatchJoin)`, `the wait-all join MUST record one ${o.types.joined} (got ${joined.length})`),
|
|
188
|
+
finding(j['completedCount'] === WAVES_CHILDREN && j['failedCount'] === 0, `${DOC_FANOUT} (none is dropped)`, `${o.types.joined} MUST count ${WAVES_CHILDREN} completed and 0 failed (got ${JSON.stringify({ completedCount: j['completedCount'], failedCount: j['failedCount'] })})`),
|
|
189
|
+
finding(Array.isArray(j['mergeOrder']) && j['mergeOrder'].length === WAVES_CHILDREN, `${DOC_FANOUT} (run-event-payloads dispatchJoin.mergeOrder)`, `${o.types.joined}.mergeOrder MUST list all ${WAVES_CHILDREN} children (got ${JSON.stringify(j['mergeOrder'])})`),
|
|
190
|
+
finding(o.childRunIds.length === WAVES_CHILDREN, `${DOC_FANOUT} (none is dropped)`, `${WAVES_CHILDREN} distinct ${WAVES_CHILD} child runs MUST be dispatched (found ${o.childRunIds.length})`),
|
|
191
|
+
...o.children.map((c, i) => finding(c.status === 200 && c.snapshot['status'] === 'completed', `fixtures.md ${WAVES_CHILD}`, `child run ${o.childRunIds[i]} MUST be readable and end completed (GET ${c.status}, status ${JSON.stringify(c.snapshot['status'])})`)),
|
|
192
|
+
];
|
|
193
|
+
if (timed && o.children.length > 0) {
|
|
194
|
+
const n = maxInFlight(windows as ReadonlyArray<readonly [number, number]>);
|
|
195
|
+
findings.push(finding(n <= o.ceiling, `${DOC_FANOUT} (children beyond the ceiling are dispatched in waves)`, `no more than ${o.ceiling} children may be in flight at once (${n} overlapped by more than ${OVERLAP_TOLERANCE_MS} ms)`));
|
|
196
|
+
}
|
|
197
|
+
return observed(findings);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The dispatch node failed `validation_error` and no child exists — at run time, or by a `400` at `createRun`. Pure. */
|
|
201
|
+
export function judgeFailClosed(o: FanOutObservation & { createBody: unknown }, doc: string, why: string): Outcome {
|
|
202
|
+
const r = o.run;
|
|
203
|
+
if (r.createStatus === 400) {
|
|
204
|
+
const err = isRecord(o.createBody) ? o.createBody['error'] : undefined;
|
|
205
|
+
return observed([finding(err === 'validation_error', doc, `${why}: a refusal at createRun MUST be 400 validation_error (got ${JSON.stringify(err)})`)]);
|
|
206
|
+
}
|
|
207
|
+
const failed = r.events.filter((e) => e.type === 'node.failed' && e.nodeId === DISPATCH_NODE);
|
|
208
|
+
const code = isRecord(failed[0]?.payload['error']) ? failed[0]!.payload['error']['code'] : undefined;
|
|
209
|
+
return observed([
|
|
210
|
+
finding(r.createStatus === 201 || r.createStatus === 202, 'runs.md createRun', `createRun of ${r.workflowId} MUST be accepted or refused 400 validation_error (got ${r.createStatus})`),
|
|
211
|
+
finding(code === 'validation_error', doc, `${why}: the dispatch node MUST fail with validation_error (node.failed ${failed.length === 0 ? 'absent' : `code ${JSON.stringify(code)}`})`),
|
|
212
|
+
finding(o.childRunIds.length === 0 && ofType(o, o.types.dispatched).length === 0, doc, `${why}: no child may be dispatched (found ${o.childRunIds.length})`),
|
|
213
|
+
finding(r.terminalStatus === 'failed', doc, `the run MUST end failed (got ${String(r.terminalStatus)})`),
|
|
214
|
+
]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Leg: at `perItemInput: true` each child receives its own item; otherwise fail closed. Pure. */
|
|
218
|
+
export function judgePerItem(o: PerItemObservation): Outcome {
|
|
219
|
+
if (!o.perItemInput) return judgeFailClosed(o, `${DOC_FANOUT} perItemInput; ${DOC_ITEMS}`, 'a host without agents.fanOut.perItemInput: true receiving non-empty nextWorkerInputs');
|
|
220
|
+
const got = o.children.map((c) => (isRecord(c.snapshot['inputs']) ? c.snapshot['inputs']['childGreeting'] : undefined));
|
|
221
|
+
const sorted = [...got].map(String).sort();
|
|
222
|
+
return observed([
|
|
223
|
+
...runCompleted(o.run),
|
|
224
|
+
finding(o.childRunIds.length === PER_ITEM_VALUES.length, `${DOC_ITEMS} (index-aligned with nextWorkerIds)`, `${PER_ITEM_VALUES.length} ${PER_ITEM_CHILD} child runs MUST be dispatched (found ${o.childRunIds.length})`),
|
|
225
|
+
...o.children.map((c, i) => finding(c.status === 200, 'runs.md getRun', `child run ${o.childRunIds[i]} MUST be readable (GET ${c.status})`)),
|
|
226
|
+
finding(JSON.stringify(sorted) === JSON.stringify([...PER_ITEM_VALUES]), `${DOC_FANOUT} perItemInput; ${DOC_ITEMS}`, `each child MUST receive its own nextWorkerInputs[i]: inputs.childGreeting MUST be ${JSON.stringify(PER_ITEM_VALUES)} across the children (got ${JSON.stringify(got)})`),
|
|
227
|
+
]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Leg: a length mismatch fails the dispatch node and dispatches no child. Pure. */
|
|
231
|
+
export function judgeLengthMismatch(o: PerItemObservation): Outcome {
|
|
232
|
+
return judgeFailClosed(o, DOC_ITEMS, 'nextWorkerInputs.length differs from nextWorkerIds.length');
|
|
233
|
+
}
|
|
@@ -35,6 +35,8 @@ export interface RunRead { readonly status: number; readonly snapshot: Record<st
|
|
|
35
35
|
export interface ObservedRun {
|
|
36
36
|
readonly workflowId: string;
|
|
37
37
|
readonly createStatus: number;
|
|
38
|
+
/** The `createRun` response body. */
|
|
39
|
+
readonly createBody?: unknown;
|
|
38
40
|
readonly runId: string | undefined;
|
|
39
41
|
readonly terminalStatus: string | undefined;
|
|
40
42
|
/** The last `GET /runs/{runId}` body. */
|
|
@@ -78,11 +80,11 @@ export async function observeRun(profile: MajorProfile, workflowId: string, opts
|
|
|
78
80
|
const created = await driver.post(profile.runsPath, { workflowId });
|
|
79
81
|
const runId = isRecord(created.json) && typeof created.json['runId'] === 'string' ? created.json['runId'] : undefined;
|
|
80
82
|
if ((created.status !== 201 && created.status !== 202) || runId === undefined) {
|
|
81
|
-
return { workflowId, createStatus: created.status, runId: undefined, terminalStatus: undefined, snapshot: {}, events: [] };
|
|
83
|
+
return { workflowId, createStatus: created.status, createBody: created.json, runId: undefined, terminalStatus: undefined, snapshot: {}, events: [] };
|
|
82
84
|
}
|
|
83
85
|
const r = await pollTerminal(profile, runId, opts);
|
|
84
86
|
const status = r.snapshot['status'];
|
|
85
|
-
return { workflowId, createStatus: created.status, runId, terminalStatus: typeof status === 'string' ? status : undefined, snapshot: r.snapshot, events: await readEvents(profile, runId) };
|
|
87
|
+
return { workflowId, createStatus: created.status, createBody: created.json, runId, terminalStatus: typeof status === 'string' ? status : undefined, snapshot: r.snapshot, events: await readEvents(profile, runId) };
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
/** The create was accepted and the fixture run ended `completed`, as its catalog entry says. */
|
|
@@ -30,6 +30,10 @@ export interface DoubleRun {
|
|
|
30
30
|
readonly status: string;
|
|
31
31
|
readonly variables?: Record<string, unknown> | undefined;
|
|
32
32
|
readonly inputs?: Record<string, unknown> | undefined;
|
|
33
|
+
/** Further snapshot members (`startedAt`, `completedAt`, …). */
|
|
34
|
+
readonly snapshot?: Record<string, unknown> | undefined;
|
|
35
|
+
/** Refuse the create with this answer instead of making the run. */
|
|
36
|
+
readonly refuse?: { readonly status: number; readonly body: unknown } | undefined;
|
|
33
37
|
readonly events?: readonly DoubleEvent[];
|
|
34
38
|
/** The `GET /runs/{runId}/ancestry` answer, given the run's own id. Unset: 404. */
|
|
35
39
|
readonly ancestry?: ((self: string) => { readonly status: number; readonly body: unknown }) | undefined;
|
|
@@ -103,6 +107,7 @@ export class RunGraphDouble {
|
|
|
103
107
|
const self = this.mint();
|
|
104
108
|
const run = this.opts.onCreate?.(workflowId, (child) => { const id = this.mint(); this.runs.set(id, child); return id; }, self);
|
|
105
109
|
if (run === undefined) return this.send(res, 404, { error: 'not_found', message: `no workflow ${workflowId}` });
|
|
110
|
+
if (run.refuse !== undefined) return this.send(res, run.refuse.status, run.refuse.body);
|
|
106
111
|
this.runs.set(self, { workflowId, ...run });
|
|
107
112
|
return this.send(res, 201, { runId: self, status: 'running' });
|
|
108
113
|
}
|
|
@@ -128,6 +133,7 @@ export class RunGraphDouble {
|
|
|
128
133
|
runId: id, workflowId: run.workflowId ?? 'double', status: run.status,
|
|
129
134
|
...(run.variables === undefined ? {} : { variables: run.variables }),
|
|
130
135
|
...(run.inputs === undefined ? {} : { inputs: run.inputs }),
|
|
136
|
+
...(run.snapshot ?? {}),
|
|
131
137
|
});
|
|
132
138
|
}
|
|
133
139
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `agents.fanOut` breadth and per-item input (RFC 0243 §A.3, §A.4; gap
|
|
3
|
+
* G2). The facet (`spec/v2/facets/agents.schema.json` §fanOut) states both:
|
|
4
|
+
* "children beyond [`maxFanOut`] are dispatched in waves and none is dropped",
|
|
5
|
+
* and "without [`perItemInput: true`], a non-empty `nextWorkerInputs` MUST fail
|
|
6
|
+
* the dispatch node with `validation_error` and dispatch no child".
|
|
7
|
+
* `orchestrator-decision.schema.json` `nextWorkerInputs` adds the length rule.
|
|
8
|
+
* The v1 twins are `dispatch-fanout-parallel` and `dispatch-per-item-input`,
|
|
9
|
+
* which drive a v1 seam; these legs need none. Each fixture's supervisor
|
|
10
|
+
* carries a `mockDispatchPlan`, and the legs live in `lib/fan-out-witness.ts`.
|
|
11
|
+
*
|
|
12
|
+
* waves `conformance-dispatch-fanout-waves`: three `conformance-delay`
|
|
13
|
+
* children under `maxConcurrency: 2` all complete, the
|
|
14
|
+
* fan-out and join events count three, and no more than
|
|
15
|
+
* `min(2, maxFanOut)` children are in flight at once;
|
|
16
|
+
* per-item `conformance-dispatch-per-item`: at `perItemInput: true` each
|
|
17
|
+
* child receives its own item; otherwise the dispatch node
|
|
18
|
+
* fails `validation_error` and no child is dispatched;
|
|
19
|
+
* mismatch `conformance-dispatch-per-item-mismatch`: one item for two
|
|
20
|
+
* children fails the dispatch node `validation_error`, no child.
|
|
21
|
+
*
|
|
22
|
+
* Dispositions: `agents.dispatch` not `true`, or a fixture (or its child) not
|
|
23
|
+
* in `fixtures[]` ⇒ `inapplicable`; for waves, `agents.fanOut` absent or not
|
|
24
|
+
* admitting `wait-all` with `collect` ⇒ `inapplicable`; discovery unreadable ⇒
|
|
25
|
+
* `blocked`.
|
|
26
|
+
*
|
|
27
|
+
* Proven against doubles in `lib/fan-out-witness.test.ts`.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v2/facets/agents.schema.json §fanOut
|
|
30
|
+
* @see schemas/v2/orchestrator-decision.schema.json §nextWorkerInputs
|
|
31
|
+
* @see RFCS/0243-dispatch-fan-out-seat.md
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
36
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
37
|
+
import { req } from '../lib/requirement-ids.js';
|
|
38
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
39
|
+
import type { Outcome, Skip } from '../lib/fixture-run-observer.js';
|
|
40
|
+
import {
|
|
41
|
+
PER_ITEM_FIXTURE, PER_ITEM_MISMATCH_FIXTURE, drivePerItem, driveWaves, judgeLengthMismatch, judgePerItem, judgeWaves,
|
|
42
|
+
type PerItemObservation, type WavesObservation,
|
|
43
|
+
} from '../lib/fan-out-witness.js';
|
|
44
|
+
|
|
45
|
+
const PROFILE = majorProfile(2);
|
|
46
|
+
const ID_WAVES = 'openwop.requirement.0243.max-fan-out-waves';
|
|
47
|
+
const ID_PER_ITEM = 'openwop.requirement.0243.per-item-input-gated';
|
|
48
|
+
const ID_MISMATCH = 'openwop.requirement.agents.dispatch-per-item-length-mismatch';
|
|
49
|
+
|
|
50
|
+
async function discovery(): Promise<Record<string, unknown> | (() => undefined)> {
|
|
51
|
+
if (!process.env['OPENWOP_BASE_URL']) return () => softSkip('inapplicable', 'no target: OPENWOP_BASE_URL is unset');
|
|
52
|
+
let doc: Record<string, unknown> | null;
|
|
53
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
54
|
+
return doc ?? (() => softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0'));
|
|
55
|
+
}
|
|
56
|
+
function assert(id: string, out: Outcome): undefined {
|
|
57
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
58
|
+
for (const x of out.findings) expect(x.ok, req(id, x.doc, x.message)).toBe(true);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const unwrap = <T,>(o: Skip | T): T | (() => undefined) => (o !== null && typeof o === 'object' && 'kind' in o && (o as Skip).kind === 'skip' ? () => softSkip((o as Skip).disposition, (o as Skip).reason) : (o as T));
|
|
62
|
+
|
|
63
|
+
describe('v2 agents.fanOut: waves and per-item input (RFC 0243 §A.3, §A.4)', () => {
|
|
64
|
+
it('children beyond the effective ceiling are dispatched in waves, and none is dropped', async () => {
|
|
65
|
+
const doc = await discovery();
|
|
66
|
+
if (typeof doc === 'function') return doc();
|
|
67
|
+
const o = unwrap<WavesObservation>(await driveWaves(PROFILE, doc));
|
|
68
|
+
if (typeof o === 'function') return o();
|
|
69
|
+
return assert(ID_WAVES, judgeWaves(o));
|
|
70
|
+
}, 90_000);
|
|
71
|
+
|
|
72
|
+
it('nextWorkerInputs reaches each child at perItemInput: true, and fails closed without it', async () => {
|
|
73
|
+
const doc = await discovery();
|
|
74
|
+
if (typeof doc === 'function') return doc();
|
|
75
|
+
const o = unwrap<PerItemObservation>(await drivePerItem(PROFILE, doc, PER_ITEM_FIXTURE));
|
|
76
|
+
if (typeof o === 'function') return o();
|
|
77
|
+
return assert(ID_PER_ITEM, judgePerItem(o));
|
|
78
|
+
}, 90_000);
|
|
79
|
+
|
|
80
|
+
it('a nextWorkerInputs length that differs from nextWorkerIds fails the dispatch node and dispatches no child', async () => {
|
|
81
|
+
const doc = await discovery();
|
|
82
|
+
if (typeof doc === 'function') return doc();
|
|
83
|
+
const o = unwrap<PerItemObservation>(await drivePerItem(PROFILE, doc, PER_ITEM_MISMATCH_FIXTURE));
|
|
84
|
+
if (typeof o === 'function') return o();
|
|
85
|
+
return assert(ID_MISMATCH, judgeLengthMismatch(o));
|
|
86
|
+
}, 90_000);
|
|
87
|
+
});
|