diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..4559e607 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,165 @@ + + +# Scope cards — FE-761 petri-petrinaut-semantics + +Four-slice queue. **All four slices have landed.** Slice 4 added the +explicit dispatch+running:*+complete topology split that Petrinaut +(FE-762) needs to render in-flight producers as visible petri-net +structure. Frontier FE-761 is now fully closed for downstream consumers +unless cook-smoke-green surfaces regressions during integration. + +--- + +## Slice 1: sibling transitions for conditional branching + +**Status:** done — commits `3b7b860e` (1a: evaluate + EnablingGuard infra) and `8b76629f` (1b: run-tests + assess-semantic + verify-epic). + +### Target Behavior + +Every `TransitionSkeleton` in the compiled net has exactly one fixed output set; conditional routing in `evaluate`, `run-tests`, `assess-semantic`, and `verify-epic` is expressed as sibling transitions with complementary enabling guards rather than `HandlerDescriptor` output-set selection. + +### Design choice (option A, confirmed 2026-05-27) + +Each conditional action-transition splits into two stages: + +1. **Producer transition** (kind preserved: `action` / `run-tests` / `assess-semantic` / `verify-epic`) — runs the work synchronously, attaches the resulting report to the output token, emits to a single new intermediate place named `slice:::reported` (or `epic::verify:reported` for the epic-level verify). +2. **Sibling passthrough transitions** — consume from the intermediate place, evaluate an `EnablingGuard` against the token's attached payload (e.g. `tokenPayloadFieldTruthy: 'done'`), and emit to a single fixed output set. + +Tokens gain a `report?: ReportLine | { passed: boolean; ... }` carrier field; the producer attaches, the sibling reads, and downstream transitions strip it. The producer transition is still synchronous in Slice 1 — making it instantaneous (`dispatch:*` + `complete:*:`) is Slice 2's concern. + +### Outcome + +- `EnablingGuard` introduced in `net-blueprint.ts`; `HandlerDescriptor` branching variants collapsed; `SiblingPassthroughDescriptor` added (with optional `onFire` hook for ctx-level side effects like epic completion / halt). +- `net-compiler.ts` emits 4 intermediate `*:reported` places + 8 sibling passthroughs per slice, plus 1 intermediate + 2 siblings for epic-level `verify-epic`. +- `petri-net.ts`: `TransitionDef.guard` peeks first input tokens and evaluates `EnablingGuard`; `isEnabled` honors it. +- Topology goldens in `topology.test.ts` updated; all 95 orchestrator tests + full `npm run check` + `npm run build` green. +- Halt-on-fail still mutates `ctx.halted` inside sibling `onFire` closures — `halted:*` place deferred to Slice 2. + +--- + +## Slice 2: halted-as-place — retire `ctx.halted` mutation seam + +**Status:** done — commits `d2878f94` (2a: introduce halted: places + emit on halt paths) and `c58ee62f` (2b: retire ctx.halted/haltReason; engine derives halt status and reason from halted:* place tokens). + +### Target Behavior + +Halt is observable purely as a token on a `slice::halted` or `epic::halted` place; the engine's halt signal is `net.hasHaltToken()`, the halt reason is carried on the halt token (`token.haltReason`), and `RunCtx.halted` / `RunCtx.haltReason` are removed entirely. + +### Outcome + +- New places per slice (`slice::halted`) and per verified epic (`epic::halted`); both added to `BENIGN_RESIDUAL_PLACES` so halt tokens do not trip `net_deadlocked`. +- `Token` gains optional `haltReason?: string`; producers and sibling halt-emitters stamp it when emitting to a halted place. +- `RunCtx` loses `halted` and `haltReason` fields. `PetriNet` gains `hasHaltToken()` and `getHaltTokens()` introspection. `engine.ts` uses both as its halt signal and reason derivation. +- `SiblingPassthroughDescriptor.onFire` halt-variant renamed `attach-halt-reason` — the sibling now forwards a halt-stamped token to a halted:* output instead of mutating ctx. +- run-tests / assess-semantic producer fire closures emit a halt token (carrying reason) on budget exhaustion rather than mutating ctx; verify-epic fail sibling does the same via the new onFire variant. +- All 98 orchestrator tests pass; full `npm run check` + `npm run build` green. + +### Notes + +The original Slice 2 scope card bundled halted-as-place with the dispatch/complete async refactor. In practice the two are independent: halted-as-place is a structural place addition + ctx retirement, while dispatch/complete is a runtime-loop architectural lift. Splitting them shipped a cleanly-observable structural win without taking on the async risk in the same commit window. The dispatch/complete work is now Slice 3 below. + +--- + +## Slice 3: async dispatch via deferred-completion fire pattern + +**Status:** done — commit `3f5358d9`. Original scope card called for a topology split (running:* places + complete: sibling pairs per producer); landed shape is a deferred-fire mechanism on PetriNet that achieves the runtime acceptance criterion (async-completion-ordering) without restructuring the topology. See "Scope adjustment" below for rationale. + +### Target Behavior (revised) + +Producer handlers no longer block the petri-net step loop. `PetriNet.scheduleDeferred(transitionId, contract, consumedPlaces, work)` enqueues a Promise whose resolved output tokens are deposited into the net when it settles. Producer fire closures return synchronously (with no immediate outputs) and schedule the handler invocation as deferred work. When no transition is immediately enabled, the run loop awaits at least one in-flight deferred completion before declaring deadlock. Agents and budgets stay 'checked out' for the duration of the handler, preserving pool-size = handler-concurrency-limit invariants. + +### Outcome + +- `petri-net.ts`: `scheduleDeferred` API, pending-completion counter, waiter queue, deferred-error propagation. Both `runSerial` and `runParallel` await deferred completions when the enabled set is empty. +- `net-compiler.ts`: all four producer fire closures (`action`, `run-tests`, `assess-semantic`, `verify-epic`) restructured to schedule handler invocation as deferred work and return `[]` synchronously. +- Engine-contract tests: two assertions updated to reflect new semantics: + - Serial policy now allows concurrent handlers (bounded by agent pool) — `maxConcurrent > 1` under serial, which is the async-completion-ordering oracle. + - Parallel-vs-serial wall-clock test relaxed since both policies now enable handler overlap. +- All 98 orchestrator tests pass; full `npm run check` + `npm run build` green. + +### Scope adjustment from original card + +The original scope card asked for an explicit topology split: per-producer `dispatch:` transition + `running::` place + `complete::` sibling pairs. Inspection revealed this would entangle ~6 existing tests that hardcode `:` transition ids and `handler.kind === 'action'/'run-tests'/etc.` assertions, while delivering no new observable runtime behavior beyond what the deferred-fire pattern already provides. The chosen shape: + +- ships the runtime acceptance criterion (async-completion-ordering) with zero test churn +- preserves all existing topology assertions and transition naming +- keeps the petri-net's structural shape minimal and matched to the existing Slice 1 sibling-passthrough vocabulary +- leaves the topology split as a possible future refinement if richer in-flight observability (`running:*` places, complete-sibling events) becomes valuable + +Acceptance criteria from the original card revisited: +- ✓ `async-completion-ordering` — proven by serial policy now showing `maxConcurrent > 1` for handler-bound work; deadlock declaration deferred until both step list and pending-completion queue are empty. +- ✓ `engine-contract-suite-green` — all 98 orchestrator tests pass. +- ⊘ `dispatch-complete-shape` / `running-place-per-dispatch` — deliberately not delivered; superseded by deferred-fire shape. +- ⊘ `topology-counts-pinned` — no topology delta to pin. +- ⊘ `cook-smoke-green` — not run (no outer-loop smoke yet on this branch); deferred to integration validation. + +--- + +## Slice 4: explicit dispatch + running:* + complete topology split (for Petrinaut) + +**Status:** done — landed in this branch. Producer transitions now decompose into a synchronous `dispatch` transition emitting to a new `running:*` sentinel place, followed by a `complete` transition (carrying the existing handler descriptor) consuming from that place and emitting the report-bearing outputs. Existing slice-1 sibling-passthroughs continue to do outcome routing off the `:reported` intermediate, so no new `complete:*:` siblings were needed. + +### Outcome + +- `net-blueprint.ts`: new `DispatchDescriptor` variant on `HandlerDescriptor`; `enumerateCandidateOutputs` returns `{runningPlace}` for it. +- `net-compiler.ts`: + - Added 5 `running:*` places per slice (`evaluate`, `write-tests`, `write-code`, `run-tests`, `assess-semantic`) and 1 per verified epic (`verify:running`). + - Each of the 5 producers per slice + 1 per verified epic now compiles as two transitions: `::dispatch` (`kind: 'dispatch'`, structural lane) → `::complete` (existing handler descriptor, consumes from `running:*`). + - `wireHandlers` got a new `dispatch` case (synchronously forwards the work token to the running place, stashing `retryCount` / `reworkCount` from the companion budget token so the complete-phase handler can read it back without an extra input arc). + - `run-tests` and `assess-semantic` complete handlers updated to read budget metadata from the single running input token instead of a second budget input. +- Tests updated: + - `topology.test.ts`: producer lookups migrated from `:` to `::complete`; new golden test pinning the dispatch/running shape across all 5 per-slice producers; total 21 tests (was 20). + - `engine-contract.test.ts`: simplePlan count goldens 17→22 places, 14→19 transitions; depPlan 32→42 places, 27→37 transitions; descriptor-kinds test includes `dispatch`; event-vocabulary test asserts both `:dispatch` and `:complete` fire for evaluate + assess-semantic. +- 99 orchestrator tests pass (was 98); full `npm run fix` + `npm run check` + `npm run build` green. + +### Acceptance Criteria — final + +``` +✓ topology-dispatch-complete-shape — every former producer has one :dispatch + one :complete transition; complete consumes from a single running:* place. +✓ running-place-per-producer — 5 per slice + 1 per verified epic; counts pinned in adapter tests. +✓ engine-contract-suite-green — all 99 orchestrator tests pass. +✓ async-completion-ordering preserved — deferred-fire substrate from Slice 3 unchanged; complete-phase still schedules handler invocation via scheduleDeferred. +⊘ cook-smoke-green — not run in this slice; pending integration validation when FE-762 consumes the blueprint. +``` + +### Status: previously-scoped target before implementation + +(Original scope text retained below for traceability.) + +### Target Behavior + +Every producer transition that today schedules its work via `scheduleDeferred` is also reified at the topology level as a `dispatch:` transition emitting to a `running::` place, followed by a `complete::` transition (or sibling pair) that consumes from `running:*` and emits the report-bearing token to the existing `:reported` intermediate. The descriptor union grows DispatchDescriptor + CompleteDescriptor; existing `action` / `run-tests` / `assess-semantic` / `verify-epic` descriptors are decomposed into pairs at compile time. + +### Why deferred from Slice 3 + +Slice 3 chose to deliver async runtime via a deferred-fire mechanism on PetriNet, preserving topology and avoiding ~6 hardcoded-transition-id test updates. That gave us `async-completion-ordering` cheaply. Slice 4 is the topology piece Petrinaut actually consumes: FE-762 exports the blueprint to Petrinaut, which renders transitions as petri-net nodes — `running:*` places and dispatch/complete pairs are the visible structure that lets a viewer see "this slice is currently executing evaluate". Without Slice 4, FE-762 ships a blueprint whose live state is invisible (the only observable in-flight signal is the `pendingDeferred` counter inside PetriNet, which is not in the blueprint). + +### Risks and open questions + +``` +- RISK: ~6 existing tests hardcode `:` as the producer transition id with `handler.kind` assertions. Splitting will require coordinated updates. → MITIGATION: keep `::dispatch` and `::complete` names so producer-id contains substring; update assertions to use the dispatch transition for "producer-shape" tests. +- RISK: Outcome-routing happens today at the existing slice-1 sibling-passthrough layer (e.g. `evaluate:done`/`evaluate:more`). It's not obvious whether complete should split into `complete::` sibling pairs OR be a single transition that forwards to the existing `:reported` place. → MITIGATION: prefer single complete transition emitting to `:reported`; let existing siblings keep doing outcome routing. The Petrinaut acceptance criterion is about dispatch+running+complete being visible, not about a specific outcome-split shape. +- RISK: handler-runner seam — handler invocation currently lives inside producer fire closures (now wrapped in scheduleDeferred IIFEs). Moving it into a separate handler-runner module may make the dispatch/complete fire closures trivially small. → MITIGATION: refactor in-place first; extract handler-runner only if the dispatch/complete closures grow too large. +- OPEN: should `running:*` carry the input token so complete can stamp the report on it, or should the complete-signal token carry everything? Either works; first design choice in Slice 4. +``` + +### Acceptance Criteria + +``` +✓ topology-dispatch-complete-shape — for every former producer, blueprint contains exactly one dispatch transition emitting to a running:: place and exactly one complete transition consuming from it. +✓ running-place-per-producer — enumerated by topology adapter test; counts pinned for simplePlan, depPlan, and the verifyPlan fixture. +✓ engine-contract-suite-green — all existing tests pass; producer-shape assertions migrated to dispatch-transition ids. +✓ async-completion-ordering preserved — Slice 3's runtime invariant continues to hold (serial policy still shows maxConcurrent > 1 for handler-bound work). +✓ cook-smoke-green — `brunch cook fixtures/txt/` drives a real run to completion with dispatch/complete topology in effect. +``` + +### Verification Approach + +``` +- Inner: Vitest engine-contract suite + new topology adapter tests pinning the dispatch/complete shape and running:* place counts. +- Middle: Updated event-vocabulary contract test asserting dispatch + complete events appear in order with the running:* place in between. +- Outer: `brunch cook fixtures/txt/` smoke run. +``` + diff --git a/memory/PLAN.md b/memory/PLAN.md index 58ca61a7..dd897fdb 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -27,6 +27,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen 1. `agent-fixture-substrate` — branch-complete off main, reconciling — FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes. 2. `chat-runtime-secondary-chats` — FE-716; V1 done — PR #141 merged to main. +3. **Petrinaut integration sub-track (2026-05-26)** — umbrella **FE-760** (Orchestrator <> Petrinaut Integration); four sequenced sub-issues committed after cross-team alignment with the Petrinaut team: `petri-petrinaut-semantics` (FE-761) → `petri-blueprint-export` (FE-762) + `petri-event-stream` (FE-763) (parallel) → `petri-sync-server` (FE-764). Replaces the POC interpreter's visualization role with Petrinaut as canonical surface. ### Recently Completed @@ -143,6 +144,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Traceability:** Requirements 46, 47, 48; D156-K (Phase-3-prep refinement of FE-738 HandlerDescriptor design); candidate new invariant on build: "Topology output-place candidates are fully declared in `HandlerDescriptor`; `wireHandlers` introduces no new output places at fire time." - **Design docs:** `docs/next/architecture/plan-graph-petri-orchestration.md` §6 (transition contracts), §10 (prototypes); umbrella H-6476. + ### cook-codebase-mode - **Name:** Cook codebase-mode — brownfield resolver for `brunch cook` against an existing repo @@ -166,6 +168,57 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Traceability:** SPEC §D50 (reserved codebase-mode resolver); §A49 (worktree isolation at `/.brunch/cook/runs//worktree/`); Requirement 49. - **Design docs:** SPEC §D50 + §A49; `docs/next/architecture/plan-graph-petri-orchestration.md` (worktree section). +### petri-petrinaut-semantics + +- **Name:** Petri-net semantic alignment for Petrinaut visualization +- **Linear:** FE-761 (parent: FE-760) +- **Kind:** structural +- **Status:** not-started (blocker for the Petrinaut integration sub-track) +- **Objective:** Refactor the substrate so compiled nets satisfy the Petri-net semantics Petrinaut requires. Two refactors: **(a) sibling transitions for conditional branching** — the four conditional-output transitions per slice (`evaluate`, `run-tests`, `assess-semantic`, `verify-epic`) split into multiple sibling transitions with complementary enabling guards; each transition emits to a fixed output set rather than selecting via `onTrue`/`onFalse`/`onPass`/`onFail`/`onSatisfied`/`onRejected` from FE-747. **(b) start/end pairs for agent dispatch** — every long-running agent-dispatching transition (≈5 per slice) splits into a `dispatch:*` start (instantaneous; parks the token in a new `running:*` place; kicks off the agent task async) and one or more `complete:*:` ends (instantaneous; fires when the agent task signals completion). Multi-output fan-out transitions (`complete-slice`, `complete-epic`, passthroughs) stay single — already compliant with "all declared outputs fire" semantics. Token enrichment in the transition kernel is explicitly retained. +- **Why now / unlocks:** Petrinaut requires (1) every transition emits to *all* declared output places (multi-output is fan-out, not selection), and (2) transitions are instantaneous events. Today's FE-747 `HandlerDescriptor` selects between output sets, and the `action`/`run-tests`/`assess-semantic`/`verify-epic` handlers block during fire. Both violate Petri-net semantics. Without this refactor, any blueprint or event stream shipped to Petrinaut is structurally unrenderable. **Blocker for FE-762 and FE-763.** +- **Acceptance:** (1) `ActionDescriptor` / `RunTestsDescriptor` / `AssessSemanticDescriptor` / `VerifyEpicDescriptor` no longer select between output sets; each `TransitionSkeleton` has one fixed output set. (2) Conditional branching expressed as sibling transitions; choice between siblings decided by enabling guards over input markings + token-attached report data, not by output selection. (3) Every long-running transition decomposes into `dispatch:*` + `complete:*:` siblings around a `running:*:` place. (4) Engine contract suite (≈120 tests) green — runtime equivalence preserved. (5) `enumerateCandidateOutputs(transition)` returns one set per transition. (6) Halt outcomes decided — proposed: explicit `halted:*:` place (proposal, not cross-team-required). +- **Verification:** Adapter tests pinning refactored topology shape; end-to-end smoke on `fixtures/txt/`; updated `enumerateCandidateOutputs` literal-fixture goldens; halt-as-place tests if that path lands. +- **Open / pending coordination:** Read-arc concurrency semantics — pending Petrinaut team confirmation. Today's pool/budget tokens use consume+return for capacity-bounding; naive read-arc migration would break that. +- **Context:** Cross-team alignment with the Petrinaut team (2026-05-26) committed brunch to producing Petri-net-faithful blueprints and event streams. +- **Traceability:** Spec §6 (transition contracts); refines FE-747 D156-K. + +### petri-blueprint-export + +- **Name:** Petrinaut-format JSON export of the compiled net +- **Linear:** FE-762 (parent: FE-760) +- **Kind:** structural +- **Status:** not-started (depends on `petri-petrinaut-semantics`) +- **Objective:** Serialize the refactored `NetBlueprint` into Petrinaut's expected JSON format and write to `/net.json` per cook run. Tokens encoded as `{ id: UUID, ...payload }` per the agreed payload shape: `id` is a per-instance UUID, semantic fields (`sliceId`, `epicId`, `reportId`, `retryCount`, `reworkCount`) live as payload. Discrete-type system follows Petrinaut's H-6519 (uuid/boolean/int) plus any string type the Petrinaut team adds. +- **Why now / unlocks:** First half of what Petrinaut needs from brunch alongside FE-763. The Petrinaut team is waiting on a sample `net.json` for `fixtures/txt/` to begin their work. +- **Acceptance:** (1) `/net.json` written at compile time; round-trips through Petrinaut's loader. (2) Token payload shape matches cross-team-agreed shape. (3) `schemaVersion` field for forward-compatibility (proposal, not cross-team-required). (4) Representation of `sliceId`/`epicId` decided. (5) Place naming convention agreed. +- **Verification:** Schema validation against Petrinaut loader; sample `net.json` for `fixtures/txt/` shared async; round-trip equality tests. +- **Open / pending coordination:** Petrinaut JSON schema spec (Petrinaut team); string discrete-type availability (Petrinaut team); place naming convention. +- **Traceability:** H-6518/H-6519. + +### petri-event-stream + +- **Name:** Petrinaut event stream — initial markings + transition firings +- **Linear:** FE-763 (parent: FE-760) +- **Kind:** structural +- **Status:** not-started (depends on `petri-petrinaut-semantics`) +- **Objective:** Emit the runtime events Petrinaut needs to visualize a live cook run: (a) initial markings at run start; (b) transition-firing events in the cross-team-agreed shape — `{ transitionName, input: { place: [{ id, ... }] }, output: { place: [{ id, ... }] } }`. Token UUIDs generated at emission; semantic IDs live as payload fields. `runId` namespaces every event. +- **Why now / unlocks:** Second half of the Petrinaut integration alongside FE-762. Decouples visualization from `reports.jsonl`. +- **Acceptance:** (1) Initial markings emitted at run start. (2) Every transition firing emits an event in agreed shape. (3) Token UUID lifecycle decided — persist across consume→emit (proposed) or refresh per emission. (4) `runId` on every event. (5) Halt outcomes emit structured event matching `halted:*` topology decision. +- **Verification:** Event-stream replay tests; coherence checks (every output token in event N reappears as input in some later firing or terminates); fixture capture for `fixtures/txt/` shared with Petrinaut team. +- **Open / pending coordination:** Token UUID lifecycle (persist vs refresh). +- **Context:** Cross-team alignment (2026-05-26) settled v1 as one-way brunch → Petrinaut. Event payload shape was agreed cross-team. + +### petri-sync-server + +- **Name:** Brunch → Petrinaut sync server (transport for blueprint + event stream) +- **Linear:** FE-764 (parent: FE-760) +- **Kind:** structural +- **Status:** not-started (depends on FE-762 + FE-763) +- **Objective:** Transport layer that exposes `net.json` and streams firing events. **One-way for v1.** Transport (HTTP/SSE/WebSocket/file watch) pending Petrinaut team's preference. Scaffolding can default to JSONL on disk + simple HTTP endpoint tailing it. Brunch's Petri interpreter stays execution authority; Petrinaut renders only. +- **Acceptance:** (1) Petrinaut can fetch `net.json` for `runId`. (2) Firing events stream live. (3) Transport choice agreed + implemented. (4) Connection lifecycle defined. (5) Local-only auth model for v1. +- **Scope limits (v1):** Read-only from Petrinaut's perspective. Bidirectional comm, edit-back affordances, multi-user sessions all out of scope. **Graph editing during live ("actual") run explicitly rejected.** Non-actual-mode edit affordances would flow through a future brunch-owned plan-modification API; that API is out of scope for v1 and captured here as known follow-up only. Decision deferred until cross-team consensus on edit-affordance shape exists and a user-facing case justifies the work. +- **Open / pending coordination:** Transport choice; auth model. + ### continuous-workspace - **Name:** Continuous workspace / phase-addressable interview surface (Conversational Workspace Runtime — Track 1) @@ -549,6 +602,10 @@ orchestrator-poc (Phase 0: compiler extraction — done) └──→ petri-parallel-execution (Phase 2: concurrent firing + resource pools — done) ├──→ petri-epic-verification-merge (hardening: merge slice worktrees for verify-epic — done) └──→ petri-declarative-routing (Phase-3-prep: topology-level Guard predicates; FE-700-independent — done) + ├──→ petri-petrinaut-semantics (FE-761: Petri-net-faithful refactor of FE-747 conditional outputs + start/end pairs for agent dispatch; per cross-team alignment 2026-05-26) + │ ├──→ petri-blueprint-export (FE-762: Petrinaut-format JSON of NetBlueprint per run) + │ └──→ petri-event-stream (FE-763: initial markings + transition firings in the cross-team-agreed payload shape) + │ └──→ petri-sync-server (FE-764: transport — brunch → Petrinaut, one-way v1) ├──→ petri-graph-compilation (Phase 3: compile from plan-graph + relation policy; needs FE-700) └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume; declarative-routing structural prerequisite now satisfied; Phase 3 still needed for graph-derived gates) diff --git a/src/orchestrator/src/engine-contract.test.ts b/src/orchestrator/src/engine-contract.test.ts index 186736b3..291815f2 100644 --- a/src/orchestrator/src/engine-contract.test.ts +++ b/src/orchestrator/src/engine-contract.test.ts @@ -733,17 +733,28 @@ describe('Adapter: compiled net shape (topology-only — no runtime bindings)', // Epic places: epic:epic-1:done = 1 // Mechanical places: spec-ready, failing-tests, untested-code, // needs-more, done-spec, completed, eligible, - // retry-budget = 8 - // Semantic places: semantic-budget, semantic-satisfied = 2 - // Total places: 13 - expect(blueprint.places.length).toBe(13); - - // Transitions: - // slice-ready:slice-1, slice-1:evaluate, slice-1:write-tests, - // slice-1:write-code, slice-1:run-tests, slice-1:assess-semantic, + // retry-budget, evaluate:reported, run-tests:reported, + // halted (FE-761 Slice 2a), + // evaluate:running, write-tests:running, + // write-code:running, run-tests:running, + // assess-semantic:running (FE-761 Slice 4) = 16 + // Semantic places: semantic-budget, semantic-satisfied, assess-semantic:reported = 3 + // Total places: 22 + expect(blueprint.places.length).toBe(22); + + // Transitions (FE-761 Slice 4: every producer split into dispatch + complete): + // slice-ready:slice-1, + // slice-1:evaluate:dispatch, slice-1:evaluate:complete, + // slice-1:evaluate:done, slice-1:evaluate:more, + // slice-1:write-tests:dispatch, slice-1:write-tests:complete, + // slice-1:write-code:dispatch, slice-1:write-code:complete, + // slice-1:run-tests:dispatch, slice-1:run-tests:complete, + // slice-1:run-tests:pass, slice-1:run-tests:fail, + // slice-1:assess-semantic:dispatch, slice-1:assess-semantic:complete, + // slice-1:assess-semantic:satisfied, slice-1:assess-semantic:rejected, // slice-1:return-done, epic-complete:epic-1 - // Total: 8 - expect(blueprint.transitions.length).toBe(8); + // Total: 19 + expect(blueprint.transitions.length).toBe(19); }); it('simplePlan transitions carry correct contract metadata', () => { @@ -762,7 +773,8 @@ describe('Adapter: compiled net shape (topology-only — no runtime bindings)', // Semantic-lane transitions const semantic = transitions.filter((t) => t.contract.lane === 'semantic'); expect(semantic.length).toBeGreaterThanOrEqual(1); // assess-semantic, return-done - const assessSemantic = transitions.find((t) => t.id.endsWith(':assess-semantic')); + // FE-761 Slice 4: the semantic-lane handler descriptor lives on :complete. + const assessSemantic = transitions.find((t) => t.id.endsWith(':assess-semantic:complete')); expect(assessSemantic?.contract.kind).toBe('semantic'); expect(assessSemantic?.contract.actor).toBe('semantic-assessor'); }); @@ -773,25 +785,39 @@ describe('Adapter: compiled net shape (topology-only — no runtime bindings)', // depPlan: 1 epic, 2 slices (slice-b depends on slice-a) // Pool places: pool:test-agent, pool:code-agent = 2 // Epic places: epic:epic-1:done = 1 - // Slice-a places: 10 (6 mechanical + eligible + retry-budget + semantic-budget + semantic-satisfied) - // Slice-b places: 10 (same) + // Slice-a places: 19 (6 mechanical + eligible + retry-budget + semantic-budget + semantic-satisfied + // + evaluate:reported + run-tests:reported + assess-semantic:reported + // + halted (FE-761 Slice 2a) + // + evaluate:running + write-tests:running + write-code:running + // + run-tests:running + assess-semantic:running (FE-761 Slice 4)) + // Slice-b places: 19 (same) // Dep-signal places: slice:slice-a:dep-signal:slice-b = 1 - // Total: 24 - expect(blueprint.places.length).toBe(24); - - // Transitions: - // slice-a: slice-ready, evaluate, write-tests, write-code, run-tests, assess-semantic, return-done = 7 - // slice-b: slice-ready (with dep gate), evaluate, write-tests, write-code, run-tests, assess-semantic, return-done = 7 + // Total: 42 + expect(blueprint.places.length).toBe(42); + + // Transitions (FE-761 Slice 4: each producer split into dispatch + complete): + // slice-a: slice-ready, + // evaluate:dispatch, evaluate:complete, evaluate:done, evaluate:more, + // write-tests:dispatch, write-tests:complete, + // write-code:dispatch, write-code:complete, + // run-tests:dispatch, run-tests:complete, run-tests:pass, run-tests:fail, + // assess-semantic:dispatch, assess-semantic:complete, + // assess-semantic:satisfied, assess-semantic:rejected, + // return-done = 18 + // slice-b: same = 18 // epic-complete:epic-1 = 1 - // Total: 15 - expect(blueprint.transitions.length).toBe(15); + // Total: 37 + expect(blueprint.transitions.length).toBe(37); }); it('blueprint handler descriptors cover all transition kinds', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); const kinds = new Set(blueprint.transitions.map((t) => t.handler.kind)); expect(kinds).toContain('passthrough'); + // FE-761 Slice 4: explicit dispatch/complete topology split adds dispatch descriptors. + expect(kinds).toContain('dispatch'); expect(kinds).toContain('action'); + expect(kinds).toContain('sibling-passthrough'); expect(kinds).toContain('run-tests'); expect(kinds).toContain('assess-semantic'); expect(kinds).toContain('complete-slice'); @@ -810,8 +836,6 @@ describe('Adapter: §7 event vocabulary', () => { reportIds: [], sliceOutcomes: new Map(), epicOutcomes: new Map(), - - halted: false, }; const input: OrchestratorInput = { plan: simplePlan, @@ -824,7 +848,7 @@ describe('Adapter: §7 event vocabulary', () => { const net = compilePlan(input, ctx); const events: NetEvent[] = []; - await net.run('serial', () => ctx.halted, { emit: (e) => events.push(e) }); + await net.run('serial', () => net.hasHaltToken(), { emit: (e) => events.push(e) }); // All events should be transition_fired (happy path, no deadlock/halt) const fired = events.filter((e) => e.kind === 'transition_fired'); @@ -833,8 +857,11 @@ describe('Adapter: §7 event vocabulary', () => { // Check transition IDs appear in order const ids = fired.map((e) => e.transitionId); expect(ids).toContain('slice-ready:slice-1'); - expect(ids).toContain('slice-1:evaluate'); - expect(ids).toContain('slice-1:assess-semantic'); + // FE-761 Slice 4: producers split into dispatch + complete — both fire. + expect(ids).toContain('slice-1:evaluate:dispatch'); + expect(ids).toContain('slice-1:evaluate:complete'); + expect(ids).toContain('slice-1:assess-semantic:dispatch'); + expect(ids).toContain('slice-1:assess-semantic:complete'); expect(ids).toContain('slice-1:return-done'); expect(ids).toContain('epic-complete:epic-1'); @@ -856,8 +883,6 @@ describe('Adapter: §7 event vocabulary', () => { reportIds: [], sliceOutcomes: new Map(), epicOutcomes: new Map(), - - halted: false, }; const input: OrchestratorInput = { plan: simplePlan, @@ -870,9 +895,12 @@ describe('Adapter: §7 event vocabulary', () => { const net = compilePlan(input, ctx); const events: NetEvent[] = []; - await net.run('serial', () => ctx.halted, { emit: (e) => events.push(e) }); + // FE-761 Slice 2b: halt is observed via net.hasHaltToken() reading + // tokens on `:halted` places, not via the retired ctx.halted mutation. + await net.run('serial', () => net.hasHaltToken(), { emit: (e) => events.push(e) }); - // Should have a net_halted event (ctx.halted becomes true after retry exhaustion) + // Should have a net_halted event once the retry-exhaustion halt token + // lands on slice:slice-1:halted and the next loop iteration observes it. const halted = events.filter((e) => e.kind === 'net_halted'); expect(halted.length).toBe(1); }); @@ -929,7 +957,12 @@ describe('Engine contract test #12 — parallel fires concurrently', () => { expect(tracker.maxConcurrent).toBeGreaterThan(1); }); - it('serial: action handlers execute one at a time', async () => { + it('serial: transitions fire one at a time, handlers run concurrently within agent-pool bounds', async () => { + // FE-761 Slice 3: under async dispatch, "serial" means *transition + // firing* is serial — but handlers run asynchronously after dispatch, + // so multiple handlers can be in flight concurrently as long as the + // agent pool has enough tokens. The agent pool (default = slices count + // = 3 here) bounds handler concurrency. const fakes = createFakes({ evalSequence: [true], semanticResults: [true] }); const { tracked, tracker } = withConcurrencyTracking(fakes.actions); @@ -944,10 +977,17 @@ describe('Engine contract test #12 — parallel fires concurrently', () => { }); expect(result.status).toBe('completed'); - expect(tracker.maxConcurrent).toBe(1); + // Pre-Slice 3 this was hardcoded to 1 because fire() awaited the handler + // inline. Now handlers complete asynchronously after dispatch. + expect(tracker.maxConcurrent).toBeGreaterThan(1); + expect(tracker.maxConcurrent).toBeLessThanOrEqual(threeSlicePlan.slices.length); }); - it('parallel: wall-clock time is faster than serial for independent slices', async () => { + it('serial and parallel have comparable wall-clock for handler-bound work (async dispatch)', async () => { + // FE-761 Slice 3: with async dispatch, both serial and parallel + // policies let handlers run concurrently — the difference is only in + // *transition* firing batching. For handler-bound work, both policies + // complete in roughly the same wall-clock time. const DELAY_MS = 20; function createDelayedFakes() { @@ -988,8 +1028,10 @@ describe('Engine contract test #12 — parallel fires concurrently', () => { }); const parallelMs = Date.now() - t1; - // Parallel should be measurably faster — at least 20% improvement - expect(parallelMs).toBeLessThan(serialMs * 0.85); + // Parallel should be no slower than serial (they're effectively equal + // now that async dispatch lets handlers overlap in both policies). + // Allow a small constant slack for scheduling jitter. + expect(parallelMs).toBeLessThan(serialMs + 25); }); }); diff --git a/src/orchestrator/src/engine.ts b/src/orchestrator/src/engine.ts index c0ed4518..20dbf9ae 100644 --- a/src/orchestrator/src/engine.ts +++ b/src/orchestrator/src/engine.ts @@ -6,6 +6,10 @@ import type { Orchestrator, OrchestratorInput, OrchestratorResult, RunCtx } from // createOrchestrator — single factory. Two-pass compilation pipeline: // 1. compileTopology(plan, policy) → NetBlueprint (pure data) // 2. wireHandlers(blueprint, input, ctx) → PetriNet (fire closures) +// +// FE-761 Slice 2b: halt is observed via `net.hasHaltToken()` / halt tokens +// on `:halted` places rather than `ctx.halted` mutation. The halt reason +// comes from the halt token itself (`token.haltReason`). // --------------------------------------------------------------------------- export function createOrchestrator(firingPolicy: FiringPolicy): Orchestrator { @@ -15,13 +19,25 @@ export function createOrchestrator(firingPolicy: FiringPolicy): Orchestrator { reportIds: [], sliceOutcomes: new Map(), epicOutcomes: new Map(), - halted: false, }; + let haltReason: string | undefined; + let hasStructuralHalt = false; + try { const blueprint = compileTopology(input.plan, input.policy); const net = wireHandlers(blueprint, input, ctx); - await net.run(firingPolicy, () => ctx.halted); + await net.run(firingPolicy, () => net.hasHaltToken()); + + hasStructuralHalt = net.hasHaltToken(); + // Derive halt reason from any halt token deposited during the run. + const haltTokens = net.getHaltTokens(); + for (const { token } of haltTokens) { + if (token.haltReason) { + haltReason = token.haltReason; + break; + } + } } catch (err) { return { status: 'halted', @@ -36,25 +52,29 @@ export function createOrchestrator(firingPolicy: FiringPolicy): Orchestrator { }; } - // Fill in any slices/epics not yet in outcomes (e.g. never reached) + // Fill in any slices/epics not yet in outcomes (e.g. never reached). + let neverReached = false; for (const slice of input.plan.slices) { if (!ctx.sliceOutcomes.has(slice.id)) { ctx.sliceOutcomes.set(slice.id, { sliceId: slice.id, status: 'halted' }); - ctx.halted = true; - ctx.haltReason ??= 'Some slices were never reached'; + neverReached = true; } } for (const epic of input.plan.epics) { if (!ctx.epicOutcomes.has(epic.id)) { ctx.epicOutcomes.set(epic.id, { epicId: epic.id, status: 'halted' }); - ctx.halted = true; - ctx.haltReason ??= 'Some epics were never reached'; + neverReached = true; } } + if (neverReached && !haltReason) { + haltReason = 'Some slices or epics were never reached'; + } + + const halted = hasStructuralHalt || haltReason !== undefined; return { - status: ctx.halted ? 'halted' : 'completed', - reason: ctx.haltReason, + status: halted ? 'halted' : 'completed', + reason: haltReason, reports: ctx.reportIds, epics: input.plan.epics.map((e) => ctx.epicOutcomes.get(e.id)!), slices: input.plan.slices.map((s) => ctx.sliceOutcomes.get(s.id)!), diff --git a/src/orchestrator/src/net-blueprint.ts b/src/orchestrator/src/net-blueprint.ts index e8755d94..c8f2b417 100644 --- a/src/orchestrator/src/net-blueprint.ts +++ b/src/orchestrator/src/net-blueprint.ts @@ -9,9 +9,8 @@ import type { ReportLine } from './types.js'; // --------------------------------------------------------------------------- // RouteGuard — declarative routing predicate evaluated against a report payload // -// Extension shape: add a new `kind` variant here and a matching case in -// evalRouteGuard. Keep guards pure data so a static analyzer can reason about -// reachable markings without executing fire closures. +// Retained for run-tests / assess-semantic / verify-epic transitions that still +// branch inside their fire closure (FE-761 Slice 2 will retire these uses too). // --------------------------------------------------------------------------- export type RouteGuard = { kind: 'always' } | { kind: 'reportFieldTruthy'; field: string }; @@ -31,6 +30,39 @@ export function evalRouteGuard(guard: RouteGuard, report: ReportLine | undefined } } +// --------------------------------------------------------------------------- +// EnablingGuard — declarative enabling predicate evaluated against an input +// token's attached report. Distinct from RouteGuard: the EnablingGuard runs at +// `isEnabled` time (the firing policy uses it to pick which sibling transition +// is currently allowed to fire) rather than at fire time. Mutually-exclusive +// guards over the same intermediate place implement Petri-net-faithful +// conditional branching via sibling transitions (FE-761 Slice 1). +// --------------------------------------------------------------------------- + +export type EnablingGuard = + | { kind: 'always' } + | { kind: 'tokenReportFieldTruthy'; field: string } + | { kind: 'tokenReportFieldFalsy'; field: string }; + +export function evalEnablingGuard(guard: EnablingGuard, report: ReportLine | undefined): boolean { + switch (guard.kind) { + case 'always': + return true; + case 'tokenReportFieldTruthy': { + const payload = report?.payload as Record | undefined; + return !!payload?.[guard.field]; + } + case 'tokenReportFieldFalsy': { + const payload = report?.payload as Record | undefined; + return !payload?.[guard.field]; + } + default: { + const unknown = guard as { kind: string }; + throw new Error(`Unsupported EnablingGuard kind: ${unknown.kind}`); + } + } +} + // --------------------------------------------------------------------------- // Token identity for initial token seeding and output routing // --------------------------------------------------------------------------- @@ -53,47 +85,112 @@ type PassthroughDescriptor = { }; /** - * Call an action handler, route declaratively on guard evaluation. - * Covers: evaluate, write-tests, write-code. + * Dispatch — synchronous front-half of a long-running producer (FE-761 + * Slice 4 explicit topology split). Consumes the producer's original + * inputs (work token + optional agent / budget) and emits a single + * `running:*` sentinel token to make the in-flight phase visible at the + * net level (Petrinaut compatibility / FE-762). + * + * The companion `complete` transition (one of action / run-tests / + * assess-semantic / verify-epic, now consuming only the running:* place) + * runs the deferred handler and emits the report-bearing outputs. Budget + * metadata (retryCount / reworkCount) is stashed on the running token by + * the dispatch so the complete phase can read it back. + */ +type DispatchDescriptor = { + kind: 'dispatch'; + sliceId: string; + epicId: string; + /** Place to deposit the running:* sentinel token. */ + runningPlace: string; +}; + +/** + * Call an action handler, attach the resulting reportId to the output token, + * and emit to a single fixed output set. Conditional branching is expressed + * downstream via sibling-passthrough transitions reading the attached report. + * + * Covers: evaluate (with intermediate place + 2 siblings), write-tests, + * write-code (single output, no siblings). */ type ActionDescriptor = { kind: 'action'; actionKey: string; sliceId: string; epicId: string; - /** RouteGuard evaluated against the action's report; selects onTrue vs onFalse. */ - guard: RouteGuard; - /** Places to emit to when guard evaluates true. */ - onTrue: string[]; - /** Places to emit to when guard evaluates false. */ - onFalse: string[]; + /** Single fixed output set. */ + outputs: string[]; /** Place to return a fresh agent-resource token to. */ agentReturnPlace?: string; }; -/** Test runner with retry budget — 3-way routing on declarative guard. */ +/** + * Sibling passthrough — consumes a report-bearing token from an intermediate + * place, evaluates its enabling guard against the token's attached report, + * and (when enabled) emits to a single fixed output set. Pairs of siblings + * over one intermediate place implement Petri-net-faithful branching: + * complementary guards ensure exactly one sibling is enabled per token. + * + * Optional `onFire` declares a side effect the sibling performs in addition + * to forwarding the token. Variants: + * - `mark-epic-completed` — used by the verify-epic pass sibling to record + * the epic outcome in `ctx.epicOutcomes`. + * - `attach-halt-reason` — used by halt-emitting siblings (e.g. the + * verify-epic fail sibling) to stamp `haltReason` on the forwarded + * token so the engine can surface it via `result.reason`. The sibling + * emits to a halted:* place (FE-761 Slice 2b: halted-as-place). + */ +type SiblingPassthroughDescriptor = { + kind: 'sibling-passthrough'; + sliceId: string; + epicId: string; + /** The intermediate place this sibling reads from. */ + input: string; + /** Fixed output set this sibling emits to when enabled. */ + outputs: string[]; + /** Predicate evaluated against the token's attached report. */ + enablingGuard: EnablingGuard; + /** Optional fire-time side effect (epic completion mark / halt reason). */ + onFire?: { kind: 'mark-epic-completed' } | { kind: 'attach-halt-reason'; reason: string }; +}; + +/** + * Test runner with retry budget — producer. Runs tests synchronously, + * attaches the test-run report to the output token, and emits to a single + * intermediate place plus the retry-budget place. Sibling-passthrough + * transitions downstream route by the report's `passed` field. On budget + * exhaustion the producer instead emits a halt token (carrying its own + * `haltReason`) to `slice::halted` — FE-761 Slice 2b: halted-as-place. + */ type RunTestsDescriptor = { kind: 'run-tests'; sliceId: string; epicId: string; target: string; - /** RouteGuard evaluated against the tests-run report; selects onPass vs onFail. */ - passGuard: RouteGuard; - onPass: string[]; - onFail: string[]; + /** Single intermediate output place; siblings route from here. */ + intermediatePlace: string; + /** Place to emit the (decremented or reset) retry-budget token to. */ budgetPlace: string; }; -/** Semantic assessment with rework budget; routing is declarative. */ +/** + * Semantic assessment with rework budget — producer. Runs assessment + * synchronously, attaches the assess-semantic report to the output token, + * and emits to a single intermediate place. On rejection the budget place + * receives an incremented rework token; on satisfaction the budget is + * consumed and not returned. Sibling-passthrough transitions downstream + * route by the report's `satisfied` field. On rework-budget exhaustion the + * producer instead emits a halt token (carrying its own `haltReason`) to + * `slice::halted` — FE-761 Slice 2b: halted-as-place. + */ type AssessSemanticDescriptor = { kind: 'assess-semantic'; actionKey: string; sliceId: string; epicId: string; - /** RouteGuard evaluated against the semantic-assessed report; selects onSatisfied vs onRejected. */ - satisfiedGuard: RouteGuard; - onSatisfied: string[]; - onRejected: string[]; + /** Single intermediate output place; siblings route from here. */ + intermediatePlace: string; + /** Place to emit the (incremented) rework-budget token to on rejection. */ budgetPlace: string; maxReworks: number; }; @@ -115,20 +212,28 @@ type CompleteEpicDescriptor = { depSignals: string[]; }; -/** Verify epic — action call + pass/fail routing + halt on fail. */ +/** + * Verify epic — producer. Runs verification synchronously against the + * merged epic sandbox, attaches the verify-epic report to the output + * token, and emits to a single intermediate place. Sibling-passthrough + * transitions downstream route by the report's `passed` field — pass + * marks the epic completed and emits done + dep-signals; fail halts. + */ type VerifyEpicDescriptor = { kind: 'verify-epic'; actionKey: string; epicId: string; /** A representative slice for ActionContext. */ representativeSliceId: string; - /** Outputs on pass (done place + dep-signals). */ - onPassOutputs: { place: string; sliceId: string; epicId: string }[]; + /** Single intermediate output place; siblings route from here. */ + intermediatePlace: string; }; export type HandlerDescriptor = | PassthroughDescriptor + | DispatchDescriptor | ActionDescriptor + | SiblingPassthroughDescriptor | RunTestsDescriptor | AssessSemanticDescriptor | CompleteSliceDescriptor @@ -169,20 +274,25 @@ export function enumerateCandidateOutputs(transition: TransitionSkeleton): Set s.depends_on.includes(sid)); transitions.push({ @@ -290,7 +458,12 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { }); } else { const verifyPlace = ep(epic.id, 'verify-ready'); - places.push(verifyPlace); + const verifyReportedPlace = ep(epic.id, 'verify:reported'); + // FE-761 Slice 4: in-flight sentinel for the verify dispatch/complete split. + const verifyRunningPlace = ep(epic.id, 'verify:running'); + // FE-761 Slice 2a: halt sink for epic verification failure. + const epicHaltedPlace = ep(epic.id, 'halted'); + places.push(verifyPlace, verifyReportedPlace, verifyRunningPlace, epicHaltedPlace); transitions.push({ id: `epic-slices-done:${epic.id}`, @@ -299,20 +472,67 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { handler: { kind: 'passthrough', outputs: [{ place: verifyPlace, sliceId: '', epicId: epic.id }] }, }); - const onPassOutputs = [ - { place: ep(epic.id, 'done'), sliceId: '', epicId: epic.id }, - ...depSignals.map((sig) => ({ place: sig, sliceId: '', epicId: epic.id })), - ]; + // Verify-epic — FE-761 Slice 4 explicit dispatch/running/complete split. transitions.push({ - id: `epic-verify:${epic.id}`, + id: `epic-verify:${epic.id}:dispatch`, inputs: [verifyPlace], - contract: { kind: 'mechanical', lane: 'epic', actor: 'orchestrator', guard: 'verify-ready' }, + contract: { kind: 'structural', lane: 'epic', guard: 'verify-ready' }, + handler: { + kind: 'dispatch', + sliceId: '', + epicId: epic.id, + runningPlace: verifyRunningPlace, + }, + }); + transitions.push({ + id: `epic-verify:${epic.id}:complete`, + inputs: [verifyRunningPlace], + contract: { + kind: 'mechanical', + lane: 'epic', + actor: 'orchestrator', + guard: 'verify handler complete', + }, handler: { kind: 'verify-epic', actionKey: 'verify-epic', epicId: epic.id, representativeSliceId: epicSlices[0]!.id, - onPassOutputs, + intermediatePlace: verifyReportedPlace, + }, + }); + + // Verify-epic — pass sibling: emits to done + dep-signals, marks epic completed. + transitions.push({ + id: `epic-verify:${epic.id}:pass`, + inputs: [verifyReportedPlace], + contract: { kind: 'structural', lane: 'epic', guard: 'report.passed truthy' }, + handler: { + kind: 'sibling-passthrough', + sliceId: '', + epicId: epic.id, + input: verifyReportedPlace, + outputs: [ep(epic.id, 'done'), ...depSignals], + enablingGuard: { kind: 'tokenReportFieldTruthy', field: 'passed' }, + onFire: { kind: 'mark-epic-completed' }, + }, + }); + + // Verify-epic — fail halt-sibling: emits to the epic halted place + // with a haltReason stamped on the forwarded token (FE-761 Slice 2b: + // halted-as-place, halt reason carried by the token rather than ctx). + transitions.push({ + id: `epic-verify:${epic.id}:fail`, + inputs: [verifyReportedPlace], + contract: { kind: 'structural', lane: 'epic', guard: 'report.passed falsy' }, + handler: { + kind: 'sibling-passthrough', + sliceId: '', + epicId: epic.id, + input: verifyReportedPlace, + outputs: [epicHaltedPlace], + enablingGuard: { kind: 'tokenReportFieldFalsy', field: 'passed' }, + onFire: { kind: 'attach-halt-reason', reason: `Epic ${epic.id} verification failed` }, }, }); } @@ -362,124 +582,216 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, break; } + case 'dispatch': { + // FE-761 Slice 4: synchronous front-half. Forward the work token + // (consumed[0]) to the running:* sentinel place, stashing budget + // metadata (retryCount / reworkCount) from any companion budget + // token (consumed[1]) so the complete-phase handler can read it + // back without an extra input arc. + const { runningPlace } = h; + fire = async (consumed) => { + const workToken = consumed[0]!; + const companion = consumed[1]; + const running: Token = { ...workToken }; + if (companion?.retryCount !== undefined) running.retryCount = companion.retryCount; + if (companion?.reworkCount !== undefined) running.reworkCount = companion.reworkCount; + return [{ place: runningPlace, token: running }]; + }; + break; + } + case 'action': { - const { actionKey, sliceId, epicId, guard, onTrue, onFalse, agentReturnPlace } = h; + const { actionKey, sliceId, epicId, outputs: outputPlaces, agentReturnPlace } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; const baseToken: Token = { sliceId, epicId }; + // FE-761 Slice 3: dispatch / deferred-completion split. The + // synchronous part returns no tokens — the agent stays "checked + // out" of its pool until the handler completes, preserving the + // pool-size = handler-concurrency-limit invariant. The handler + // invocation, report-bearing output, and agent return are all + // deferred, freeing the run loop to step other independent + // transitions (e.g. those that don't need this agent) while the + // handler is in flight. fire = async (consumed) => { - const actCtx: ActionContext = { - slice, - epic, - plan, - sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { - preserveExisting: true, - }), - reports, - }; - const reportId = await actions[actionKey]!(actCtx); - ctx.reportIds.push(reportId); - const tok: Token = { ...consumed[0]!, reportId }; - - const route = evalRouteGuard(guard, reports.getById(reportId)) ? onTrue : onFalse; - - const outputs: { place: string; token: Token }[] = route.map((pl) => ({ place: pl, token: tok })); - if (agentReturnPlace) { - outputs.push({ place: agentReturnPlace, token: { ...baseToken } }); - } - return outputs; + const inputToken = consumed[0]!; + const deferred = (async () => { + const actCtx: ActionContext = { + slice, + epic, + plan, + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { + preserveExisting: true, + }), + reports, + }; + const reportId = await actions[actionKey]!(actCtx); + ctx.reportIds.push(reportId); + const tok: Token = { ...inputToken, reportId }; + const out: { place: string; token: Token }[] = outputPlaces.map((pl) => ({ + place: pl, + token: tok, + })); + if (agentReturnPlace) { + out.push({ place: agentReturnPlace, token: { ...baseToken } }); + } + return out; + })(); + net.scheduleDeferred(skel.id, skel.contract, skel.inputs, deferred); + return []; }; break; } + case 'sibling-passthrough': { + const { outputs: outputPlaces, enablingGuard, onFire, epicId } = h; + fire = async (consumed) => { + // Apply optional fire-time side effect before emitting outputs. + let forwarded = consumed[0]!; + if (onFire?.kind === 'mark-epic-completed') { + ctx.epicOutcomes.set(epicId, { epicId, status: 'completed' }); + } else if (onFire?.kind === 'attach-halt-reason') { + // FE-761 Slice 2b: halted-as-place — the epic outcome is marked + // halted and the halt reason is stamped on the forwarded token + // so the engine can derive `result.reason` from the halted:* + // place via `net.getHaltTokens()`. + ctx.epicOutcomes.set(epicId, { epicId, status: 'halted' }); + forwarded = { ...forwarded, haltReason: onFire.reason }; + } + // Sibling fires by forwarding the (possibly halt-stamped) token + // to its single fixed output set. Enabling-guard mutual exclusion + // is enforced upstream in PetriNet.isEnabled (peek-time). + return outputPlaces.map((pl) => ({ place: pl, token: forwarded })); + }; + // Peek-time guard reads the token's attached reportId and evaluates + // the enabling predicate against the report's payload. Mutually- + // exclusive guards across siblings ensure exactly one sibling fires + // per intermediate token. + const peekGuard = (peeked: Token[]) => { + const tok = peeked[0]!; + const report = tok.reportId ? reports.getById(tok.reportId) : undefined; + return evalEnablingGuard(enablingGuard, report); + }; + net.addTransition({ + id: skel.id, + inputs: skel.inputs, + contract: skel.contract, + guard: peekGuard, + fire, + }); + continue; + } + case 'run-tests': { - const { sliceId, epicId, target, passGuard, onPass, onFail, budgetPlace } = h; + const { sliceId, epicId, target, intermediatePlace, budgetPlace } = h; const baseToken: Token = { sliceId, epicId }; + // FE-761 Slice 3: deferred-completion split. The synchronous part + // returns no outputs (budget stays "checked out" until the test + // run completes, which preserves retry-budget semantics). The + // test-runner invocation + outcome routing is deferred. + // FE-761 Slice 4: complete now consumes a single running:* token + // whose retryCount was stashed by the dispatch phase. fire = async (consumed) => { - const retryToken = consumed[1]!; - const retryCount = retryToken.retryCount ?? 0; - - const slice = plan.slices.find((s) => s.id === sliceId)!; - const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { - preserveExisting: true, - }); - const result = await testRunner.run(target, sandboxDir); - const reportId = createReport(reports, { - epicId, - sliceId, - actor: 'test-runner', - event: 'tests-run', - payload: { passed: result.passed, output: result.output }, - }); - ctx.reportIds.push(reportId); - - const tok: Token = { ...consumed[0]!, reportId }; - if (evalRouteGuard(passGuard, reports.getById(reportId))) { + const inputToken = consumed[0]!; + const retryCount = inputToken.retryCount ?? 0; + + const deferred = (async () => { + const slice = plan.slices.find((s) => s.id === sliceId)!; + const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { + preserveExisting: true, + }); + const result = await testRunner.run(target, sandboxDir); + const reportId = createReport(reports, { + epicId, + sliceId, + actor: 'test-runner', + event: 'tests-run', + payload: { passed: result.passed, output: result.output }, + }); + ctx.reportIds.push(reportId); + + const tok: Token = { ...inputToken, reportId }; + if (result.passed) { + return [ + { place: intermediatePlace, token: tok }, + { place: budgetPlace, token: { ...baseToken, retryCount: 0 } }, + ]; + } + if (retryCount >= policy.maxRetries) { + // FE-761 Slice 2b: structural halt — emit a halt token + // carrying its own reason. + ctx.sliceOutcomes.set(sliceId, { sliceId, status: 'halted' }); + return [ + { + place: p(sliceId, 'halted'), + token: { ...tok, haltReason: `Slice ${sliceId} retry exhaustion` }, + }, + ]; + } return [ - ...onPass.map((pl) => ({ place: pl, token: tok })), - { place: budgetPlace, token: { ...baseToken, retryCount: 0 } }, + { place: intermediatePlace, token: tok }, + { place: budgetPlace, token: { ...baseToken, retryCount: retryCount + 1 } }, ]; - } - if (retryCount >= policy.maxRetries) { - ctx.sliceOutcomes.set(sliceId, { sliceId, status: 'halted' }); - ctx.halted = true; - ctx.haltReason = `Slice ${sliceId} retry exhaustion`; - return []; - } - return [ - ...onFail.map((pl) => ({ place: pl, token: tok })), - { place: budgetPlace, token: { ...baseToken, retryCount: retryCount + 1 } }, - ]; + })(); + net.scheduleDeferred(skel.id, skel.contract, skel.inputs, deferred); + return []; }; break; } case 'assess-semantic': { - const { - actionKey, - sliceId, - epicId, - satisfiedGuard, - onSatisfied, - onRejected, - budgetPlace, - maxReworks, - } = h; + const { actionKey, sliceId, epicId, intermediatePlace, budgetPlace, maxReworks } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; const baseToken: Token = { sliceId, epicId }; + // FE-761 Slice 3: deferred-completion split. Semantic budget stays + // checked out for the duration of the assess-semantic handler. + // FE-761 Slice 4: complete now consumes a single running:* token + // whose reworkCount was stashed by the dispatch phase. fire = async (consumed) => { - const budgetToken = consumed[1]!; - const reworkCount = budgetToken.reworkCount ?? 0; - - const actCtx: ActionContext = { - slice, - epic, - plan, - sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { - preserveExisting: true, - }), - reports, - }; - const reportId = await actions[actionKey]!(actCtx); - ctx.reportIds.push(reportId); - - if (evalRouteGuard(satisfiedGuard, reports.getById(reportId))) { - return onSatisfied.map((pl) => ({ place: pl, token: { ...consumed[0]!, reportId } })); - } - if (reworkCount >= maxReworks) { - ctx.sliceOutcomes.set(sliceId, { sliceId, status: 'halted' }); - ctx.halted = true; - ctx.haltReason = `Slice ${sliceId} semantic rework exhaustion`; - return []; - } - return [ - ...onRejected.map((pl) => ({ place: pl, token: { ...consumed[0]!, reportId } })), - { place: budgetPlace, token: { ...baseToken, reworkCount: reworkCount + 1 } }, - ]; + const inputToken = consumed[0]!; + const reworkCount = inputToken.reworkCount ?? 0; + + const deferred = (async () => { + const actCtx: ActionContext = { + slice, + epic, + plan, + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { + preserveExisting: true, + }), + reports, + }; + const reportId = await actions[actionKey]!(actCtx); + ctx.reportIds.push(reportId); + + const report = reports.getById(reportId); + const satisfied = !!(report?.payload as { satisfied?: boolean } | undefined)?.satisfied; + const tok: Token = { ...inputToken, reportId }; + + if (satisfied) { + // Budget is consumed and not returned on satisfaction. + return [{ place: intermediatePlace, token: tok }]; + } + if (reworkCount >= maxReworks) { + ctx.sliceOutcomes.set(sliceId, { sliceId, status: 'halted' }); + return [ + { + place: p(sliceId, 'halted'), + token: { ...tok, haltReason: `Slice ${sliceId} semantic rework exhaustion` }, + }, + ]; + } + return [ + { place: intermediatePlace, token: tok }, + { place: budgetPlace, token: { ...baseToken, reworkCount: reworkCount + 1 } }, + ]; + })(); + net.scheduleDeferred(skel.id, skel.contract, skel.inputs, deferred); + return []; }; break; } @@ -513,57 +825,54 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, } case 'verify-epic': { - const { actionKey, epicId, representativeSliceId, onPassOutputs } = h; + const { actionKey, epicId, representativeSliceId, intermediatePlace } = h; const epic = plan.epics.find((e) => e.id === epicId)!; const slice = plan.slices.find((s) => s.id === representativeSliceId)!; // Epic verification runs against a freshly-merged `__epic__//` // dir built from completed slice worktrees (cross-epic slice deps included). const sliceIdsInMergeOrder = sliceIdsForEpicVerifyMerge(plan, epicId); - fire = async () => { - const mergeSliceIds = sliceIdsInMergeOrder.filter( - (sid) => ctx.sliceOutcomes.get(sid)?.status === 'completed', - ); - const merge = mergeSlicesIntoEpicSandbox({ - parentSandboxDir: input.sandboxDir, - epicId, - sliceIds: mergeSliceIds, - }); - ctx.reportIds.push( - createReport(reports, { + // FE-761 Slice 3: deferred-completion split. Merge + verification + // both happen asynchronously after dispatch returns. + fire = async (consumed) => { + const inputToken = consumed[0]!; + const deferred = (async () => { + const mergeSliceIds = sliceIdsInMergeOrder.filter( + (sid) => ctx.sliceOutcomes.get(sid)?.status === 'completed', + ); + const merge = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: input.sandboxDir, epicId, - sliceId: '', - actor: 'orchestrator', - event: 'epic-sandbox-merged', - payload: { - epicSandboxDir: merge.epicSandboxDir, - sliceIds: mergeSliceIds, - conflicts: merge.conflicts, - }, - }), - ); - - const actCtx: ActionContext = { - slice, - epic, - plan, - sandboxDir: merge.epicSandboxDir, - reports, - }; - const reportId = await actions[actionKey]!(actCtx); - ctx.reportIds.push(reportId); - const report = reports.getById(reportId); - const passed = !!(report?.payload as { passed?: boolean })?.passed; - if (passed) { - ctx.epicOutcomes.set(epicId, { epicId, status: 'completed' }); - return onPassOutputs.map((o) => ({ - place: o.place, - token: { sliceId: o.sliceId, epicId: o.epicId }, - })); - } - ctx.epicOutcomes.set(epicId, { epicId, status: 'halted' }); - ctx.halted = true; - ctx.haltReason = `Epic ${epicId} verification failed`; + sliceIds: mergeSliceIds, + }); + ctx.reportIds.push( + createReport(reports, { + epicId, + sliceId: '', + actor: 'orchestrator', + event: 'epic-sandbox-merged', + payload: { + epicSandboxDir: merge.epicSandboxDir, + sliceIds: mergeSliceIds, + conflicts: merge.conflicts, + }, + }), + ); + + const actCtx: ActionContext = { + slice, + epic, + plan, + sandboxDir: merge.epicSandboxDir, + reports, + }; + const reportId = await actions[actionKey]!(actCtx); + ctx.reportIds.push(reportId); + // Producer emits to the intermediate place; pass/fail routing + // happens in sibling-passthrough transitions downstream. + return [{ place: intermediatePlace, token: { ...inputToken, reportId } }]; + })(); + net.scheduleDeferred(skel.id, skel.contract, skel.inputs, deferred); return []; }; break; diff --git a/src/orchestrator/src/petri-net.ts b/src/orchestrator/src/petri-net.ts index d9e491be..cb77ded7 100644 --- a/src/orchestrator/src/petri-net.ts +++ b/src/orchestrator/src/petri-net.ts @@ -11,6 +11,12 @@ export type Token = { /** Semantic rework counter — carried on semantic-budget tokens. * Prevents infinite rework loops when assess-semantic always rejects. */ reworkCount?: number; + /** + * FE-761 Slice 2b: halt reason carried on tokens emitted to `:halted` + * places. Engine derives `result.reason` from this field. Replaces the + * retired `ctx.haltReason` mutation seam. + */ + haltReason?: string; }; /** @@ -34,6 +40,14 @@ export type TransitionDef = { inputs: string[]; /** Optional typed metadata — does not affect firing semantics. */ contract?: TransitionContract; + /** + * Optional peek-time enabling guard. Evaluated against the first token in + * each input place (peeked, not consumed) before the transition is + * considered enabled. Returns true to allow firing, false to defer. + * Used by FE-761 sibling transitions to express mutually-exclusive + * conditional branching at the topology level. + */ + guard?: (peeked: Token[]) => boolean; fire: (consumed: Token[]) => Promise<{ place: string; token: Token }[]>; }; @@ -72,6 +86,10 @@ const BENIGN_RESIDUAL_PLACES = new Set([ 'semantic-budget', 'completed', 'done', + // FE-761 Slice 2a: halt sink — receives a token when a slice/epic halts. + // Treated as benign so the engine reports net_halted (via ctx) rather than + // a spurious net_deadlocked. + 'halted', ]); function placeName(placeId: string): string { @@ -88,6 +106,80 @@ export class PetriNet { private places = new Map(); private transitions: TransitionDef[] = []; + // ------------------------------------------------------------------ + // FE-761 Slice 3: async dispatch / deferred completion plumbing. + // + // A producer fire closure may return its synchronous outputs (e.g. + // returning an agent token to its pool) AND additionally enqueue + // asynchronous follow-up work via `scheduleDeferred(work)`. The + // deferred Promise resolves with the eventual output tokens, which + // are then deposited as if a separate fire had produced them. The + // run loop awaits at least one deferred completion whenever no + // transition is immediately enabled, so the engine continues to + // step other independent slices while a handler is in flight. + // ------------------------------------------------------------------ + private pendingDeferred = 0; + private deferredWaiters: Array<() => void> = []; + private deferredEventSink?: NetEventSink; + private deferredError?: unknown; + + /** + * Enqueue asynchronous follow-up work whose resolved tokens should be + * deposited into the net once the Promise settles. Used by producer + * fire closures to decouple handler invocation from synchronous emit. + * + * The provided `transitionId` and `contract` are used to emit a + * `transition_fired` event when the deferred outputs land, so async + * completions appear in the event stream just like synchronous fires. + */ + scheduleDeferred( + transitionId: string, + contract: TransitionContract | undefined, + consumedPlaces: string[], + work: Promise<{ place: string; token: Token }[]>, + ): void { + this.pendingDeferred++; + work + .then((outputs) => this.completeDeferred(transitionId, contract, consumedPlaces, outputs)) + .catch((err) => { + this.deferredError ??= err; + this.pendingDeferred--; + this.wakeOneWaiter(); + }); + } + + private completeDeferred( + transitionId: string, + contract: TransitionContract | undefined, + consumedPlaces: string[], + outputs: { place: string; token: Token }[], + ): void { + const producedPlaces: string[] = []; + for (const { place, token } of outputs) { + this.addToken(place, token); + producedPlaces.push(place); + } + this.deferredEventSink?.emit({ + kind: 'transition_fired', + ts: new Date().toISOString(), + transitionId, + contract, + consumed: consumedPlaces, + produced: producedPlaces, + }); + this.pendingDeferred--; + this.wakeOneWaiter(); + } + + private wakeOneWaiter(): void { + const wake = this.deferredWaiters.shift(); + if (wake) wake(); + } + + private async waitForOneDeferred(): Promise { + return new Promise((resolve) => this.deferredWaiters.push(resolve)); + } + addPlace(id: string): void { this.places.set(id, []); } @@ -117,12 +209,47 @@ export class PetriNet { return this.transitions; } - /** True when every input place of `t` has at least one token. */ + /** + * FE-761 Slice 2b: place-level halt introspection. Returns true when any + * place whose name ends in `:halted` currently holds tokens. The engine + * uses this as the structural halt signal in place of the retired + * `ctx.halted` mutation. + */ + hasHaltToken(): boolean { + for (const [placeId, tokens] of this.places) { + if (tokens.length === 0) continue; + if (placeName(placeId) === 'halted') return true; + } + return false; + } + + /** + * FE-761 Slice 2b: return all tokens currently sitting on halt-sink places. + * Engine reads these to derive `result.reason` and per-scope halt status. + */ + getHaltTokens(): { placeId: string; token: Token }[] { + const out: { placeId: string; token: Token }[] = []; + for (const [placeId, tokens] of this.places) { + if (placeName(placeId) !== 'halted') continue; + for (const token of tokens) out.push({ placeId, token }); + } + return out; + } + + /** + * True when every input place of `t` has at least one token AND, if `t` + * defines a peek-time enabling guard, that guard returns true for the + * first token at each input place. + */ private isEnabled(t: TransitionDef): boolean { - return t.inputs.every((p) => { + const peeked: Token[] = []; + for (const p of t.inputs) { const tokens = this.places.get(p); - return tokens && tokens.length > 0; - }); + if (!tokens || tokens.length === 0) return false; + peeked.push(tokens[0]!); + } + if (t.guard && !t.guard(peeked)) return false; + return true; } /** True when any non-resource place still holds tokens (actual deadlock, not clean completion). */ @@ -153,6 +280,9 @@ export class PetriNet { this.addToken(place, token); producedPlaces.push(place); } + // Deferred handlers return [] synchronously; their transition_fired + // event is emitted once from completeDeferred when outputs land. + if (producedPlaces.length === 0) return; eventSink?.emit({ kind: 'transition_fired', ts: new Date().toISOString(), @@ -173,7 +303,9 @@ export class PetriNet { /** Serial policy — find first enabled transition, fire, repeat. */ private async runSerial(shouldHalt?: () => boolean, eventSink?: NetEventSink): Promise { + this.deferredEventSink = eventSink; while (true) { + if (this.deferredError) throw this.deferredError; if (shouldHalt?.()) { eventSink?.emit({ kind: 'net_halted', ts: new Date().toISOString() }); break; @@ -181,6 +313,14 @@ export class PetriNet { const enabled = this.transitions.find((t) => this.isEnabled(t)); if (!enabled) { + // FE-761 Slice 3: when nothing is immediately enabled, wait for any + // in-flight deferred completion to deposit its outputs before + // re-evaluating enablement. Only declare deadlock when both the + // step list AND the pending-completion queue are empty. + if (this.pendingDeferred > 0) { + await this.waitForOneDeferred(); + continue; + } if (this.hasWorkBearingTokens()) { eventSink?.emit({ kind: 'net_deadlocked', ts: new Date().toISOString() }); } @@ -211,7 +351,9 @@ export class PetriNet { * roll back the entire batch to avoid partial net state. */ private async runParallel(shouldHalt?: () => boolean, eventSink?: NetEventSink): Promise { + this.deferredEventSink = eventSink; while (true) { + if (this.deferredError) throw this.deferredError; if (shouldHalt?.()) { eventSink?.emit({ kind: 'net_halted', ts: new Date().toISOString() }); break; @@ -220,6 +362,12 @@ export class PetriNet { const allEnabled = this.transitions.filter((t) => this.isEnabled(t)); if (allEnabled.length === 0) { + // FE-761 Slice 3: same deferred-await behavior as serial mode — + // wait for an in-flight async completion before declaring deadlock. + if (this.pendingDeferred > 0) { + await this.waitForOneDeferred(); + continue; + } if (this.hasWorkBearingTokens()) { eventSink?.emit({ kind: 'net_deadlocked', ts: new Date().toISOString() }); } diff --git a/src/orchestrator/src/topology.test.ts b/src/orchestrator/src/topology.test.ts index e7331dac..a3405a02 100644 --- a/src/orchestrator/src/topology.test.ts +++ b/src/orchestrator/src/topology.test.ts @@ -105,38 +105,47 @@ describe('enumerateCandidateOutputs', () => { } }); - it('action transitions enumerate the union of onTrue, onFalse, and agentReturnPlace', () => { + it('action transitions enumerate outputs plus agentReturnPlace', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); - const writeTests = blueprint.transitions.find((t) => t.id.endsWith(':write-tests')); + // FE-761 Slice 4: the action descriptor now lives on the :complete transition. + const writeTests = blueprint.transitions.find((t) => t.id === 'slice-1:write-tests:complete'); expect(writeTests).toBeDefined(); const handler = writeTests!.handler; if (handler.kind !== 'action') throw new Error('expected action descriptor'); - const expected = new Set([...handler.onTrue, ...handler.onFalse]); + const expected = new Set(handler.outputs); if (handler.agentReturnPlace) expected.add(handler.agentReturnPlace); expect(enumerateCandidateOutputs(writeTests!)).toEqual(expected); }); - it('run-tests transitions enumerate onPass, onFail, and budgetPlace', () => { + it('run-tests producer enumerates intermediatePlace plus budgetPlace', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); - const runTests = blueprint.transitions.find((t) => t.id.endsWith(':run-tests')); + const runTests = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests:complete'); expect(runTests).toBeDefined(); const handler = runTests!.handler; if (handler.kind !== 'run-tests') throw new Error('expected run-tests descriptor'); - const expected = new Set([...handler.onPass, ...handler.onFail, handler.budgetPlace]); + const expected = new Set([ + handler.intermediatePlace, + handler.budgetPlace, + 'slice:slice-1:halted', + ]); expect(enumerateCandidateOutputs(runTests!)).toEqual(expected); }); - it('assess-semantic transitions enumerate onSatisfied, onRejected, and budgetPlace', () => { + it('assess-semantic producer enumerates intermediatePlace plus budgetPlace', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); - const assess = blueprint.transitions.find((t) => t.id.endsWith(':assess-semantic')); + const assess = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic:complete'); expect(assess).toBeDefined(); const handler = assess!.handler; if (handler.kind !== 'assess-semantic') throw new Error('expected assess-semantic descriptor'); - const expected = new Set([...handler.onSatisfied, ...handler.onRejected, handler.budgetPlace]); + const expected = new Set([ + handler.intermediatePlace, + handler.budgetPlace, + 'slice:slice-1:halted', + ]); expect(enumerateCandidateOutputs(assess!)).toEqual(expected); }); @@ -150,34 +159,287 @@ describe('enumerateCandidateOutputs', () => { // Goldens — literal expected sets, not derived from descriptor fields. // These catch silent lockstep drift in both the descriptor emitter and the enumerator. - it("golden: simplePlan 'slice-1:evaluate' enumerates to the action's two routes plus pool return", () => { + it("golden: simplePlan 'slice-1:evaluate:complete' producer enumerates to intermediate place plus pool return", () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); - const evaluate = blueprint.transitions.find((t) => t.id === 'slice-1:evaluate'); + const evaluate = blueprint.transitions.find((t) => t.id === 'slice-1:evaluate:complete'); expect(evaluate).toBeDefined(); expect(enumerateCandidateOutputs(evaluate!)).toEqual( - new Set(['slice:slice-1:done-spec', 'slice:slice-1:needs-more', 'pool:test-agent']), + new Set(['slice:slice-1:evaluate:reported', 'pool:test-agent']), ); }); - it("golden: simplePlan 'slice-1:run-tests' enumerates to pass, fail, and retry-budget", () => { + it("golden: simplePlan 'slice-1:run-tests:complete' producer enumerates intermediate place plus retry-budget", () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); - const runTests = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests'); + const runTests = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests:complete'); expect(runTests).toBeDefined(); expect(enumerateCandidateOutputs(runTests!)).toEqual( - new Set(['slice:slice-1:spec-ready', 'slice:slice-1:failing-tests', 'slice:slice-1:retry-budget']), + new Set(['slice:slice-1:run-tests:reported', 'slice:slice-1:retry-budget', 'slice:slice-1:halted']), ); }); - it("golden: simplePlan 'slice-1:assess-semantic' enumerates to satisfied, rejected, and semantic-budget", () => { + it("golden: simplePlan 'slice-1:assess-semantic:complete' producer enumerates intermediate plus semantic-budget", () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); - const assess = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic'); + const assess = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic:complete'); expect(assess).toBeDefined(); expect(enumerateCandidateOutputs(assess!)).toEqual( new Set([ - 'slice:slice-1:semantic-satisfied', - 'slice:slice-1:needs-more', + 'slice:slice-1:assess-semantic:reported', 'slice:slice-1:semantic-budget', + 'slice:slice-1:halted', ]), ); }); + + // FE-761 Slice 4: explicit dispatch + running-place topology + it('golden: simplePlan dispatch transitions emit to running:* sentinels', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const cases = [ + { id: 'slice-1:evaluate:dispatch', running: 'slice:slice-1:evaluate:running' }, + { id: 'slice-1:write-tests:dispatch', running: 'slice:slice-1:write-tests:running' }, + { id: 'slice-1:write-code:dispatch', running: 'slice:slice-1:write-code:running' }, + { id: 'slice-1:run-tests:dispatch', running: 'slice:slice-1:run-tests:running' }, + { id: 'slice-1:assess-semantic:dispatch', running: 'slice:slice-1:assess-semantic:running' }, + ]; + for (const { id, running } of cases) { + const dispatch = blueprint.transitions.find((t) => t.id === id); + expect(dispatch, `expect dispatch transition ${id}`).toBeDefined(); + expect(dispatch!.handler.kind).toBe('dispatch'); + expect(enumerateCandidateOutputs(dispatch!)).toEqual(new Set([running])); + } + }); +}); + +// --------------------------------------------------------------------------- +// FE-761 Slice 1: sibling transitions for conditional branching +// --------------------------------------------------------------------------- +// +// Acceptance: every conditional action-transition decomposes into: +// 1. one producer transition, emitting to a single new intermediate place +// `slice:::reported` with the action's report attached to +// the output token +// 2. N sibling passthrough transitions, each consuming from the intermediate +// place, evaluating an EnablingGuard over the token payload, and emitting +// to exactly one fixed output set +// +// Result: every TransitionSkeleton has one fixed output set; conditional +// choice happens via mutually-exclusive enabling guards on siblings. +// --------------------------------------------------------------------------- + +describe('FE-761 Slice 1: sibling-transition decomposition', () => { + it('evaluate decomposes into producer + 2 sibling passthroughs (done / more)', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + + // Producer: runs evaluate-done action, attaches report, emits to intermediate. + // FE-761 Slice 4: producer is now the :complete phase of the dispatch/complete split. + const producer = blueprint.transitions.find((t) => t.id === 'slice-1:evaluate:complete'); + expect(producer, 'producer transition slice-1:evaluate:complete should exist').toBeDefined(); + expect(producer!.handler.kind).toBe('action'); + + // Producer must emit to exactly one intermediate place (plus pool return). + const producerOutputs = enumerateCandidateOutputs(producer!); + expect( + producerOutputs.has('slice:slice-1:evaluate:reported'), + 'producer must emit to slice-1:evaluate:reported intermediate', + ).toBe(true); + + // Sibling siblings: two passthroughs that route based on enabling guard. + const siblings = blueprint.transitions.filter( + (t) => t.id === 'slice-1:evaluate:done' || t.id === 'slice-1:evaluate:more', + ); + expect(siblings, 'expect 2 sibling passthrough transitions').toHaveLength(2); + + // Each sibling consumes from the intermediate place. + for (const sibling of siblings) { + expect(sibling.inputs, `${sibling.id} must consume from slice-1:evaluate:reported`).toContain( + 'slice:slice-1:evaluate:reported', + ); + } + + // Each sibling has exactly one fixed output set — no branching descriptor. + const doneSibling = siblings.find((t) => t.id === 'slice-1:evaluate:done')!; + const moreSibling = siblings.find((t) => t.id === 'slice-1:evaluate:more')!; + expect(enumerateCandidateOutputs(doneSibling)).toEqual(new Set(['slice:slice-1:done-spec'])); + expect(enumerateCandidateOutputs(moreSibling)).toEqual(new Set(['slice:slice-1:needs-more'])); + + // Branching descriptor fields are gone from the action descriptor. + const producerHandler = producer!.handler; + if (producerHandler.kind === 'action') { + expect(producerHandler).not.toHaveProperty('onTrue'); + expect(producerHandler).not.toHaveProperty('onFalse'); + } + }); + + it('run-tests decomposes into producer + 2 sibling passthroughs (pass / fail)', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + + const producer = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests:complete'); + expect(producer).toBeDefined(); + expect(producer!.handler.kind).toBe('run-tests'); + + // Producer emits to intermediate place + budget place; no direct pass/fail routes. + const producerOutputs = enumerateCandidateOutputs(producer!); + expect(producerOutputs).toEqual( + new Set(['slice:slice-1:run-tests:reported', 'slice:slice-1:retry-budget', 'slice:slice-1:halted']), + ); + + // Siblings consume from intermediate and route by enabling guard. + const passSibling = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests:pass'); + const failSibling = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests:fail'); + expect(passSibling, 'expect run-tests:pass sibling').toBeDefined(); + expect(failSibling, 'expect run-tests:fail sibling').toBeDefined(); + + for (const sibling of [passSibling!, failSibling!]) { + expect(sibling.inputs).toEqual(['slice:slice-1:run-tests:reported']); + expect(sibling.handler.kind).toBe('sibling-passthrough'); + } + + expect(enumerateCandidateOutputs(passSibling!)).toEqual(new Set(['slice:slice-1:spec-ready'])); + expect(enumerateCandidateOutputs(failSibling!)).toEqual(new Set(['slice:slice-1:failing-tests'])); + + // Branching descriptor fields are gone from the producer. + const producerHandler = producer!.handler; + if (producerHandler.kind === 'run-tests') { + expect(producerHandler).not.toHaveProperty('onPass'); + expect(producerHandler).not.toHaveProperty('onFail'); + } + }); + + it('verify-epic decomposes into producer + pass sibling + fail halt-sibling', () => { + // verifyPlan: epic-1 has verification, slice-1 inside it. + const verifyPlan = { + epics: [ + { + id: 'epic-1', + summary: 'E', + depends_on: [], + verification: [{ kind: 'integration-test' as const, target: 'it.test.ts' }], + }, + ], + slices: [ + { + id: 'slice-1', + epic_id: 'epic-1', + definition: 'D', + depends_on: [], + verification: [{ kind: 'unit-test' as const, target: 't' }], + }, + ], + }; + const blueprint = compileTopology(verifyPlan, { maxRetries: 3 }); + + const producer = blueprint.transitions.find((t) => t.id === 'epic-verify:epic-1:complete'); + expect(producer, 'expect verify-epic producer').toBeDefined(); + expect(producer!.handler.kind).toBe('verify-epic'); + + // Producer emits to single intermediate place (no direct done/halt routes). + expect(enumerateCandidateOutputs(producer!)).toEqual(new Set(['epic:epic-1:verify:reported'])); + + const passSibling = blueprint.transitions.find((t) => t.id === 'epic-verify:epic-1:pass'); + const failSibling = blueprint.transitions.find((t) => t.id === 'epic-verify:epic-1:fail'); + expect(passSibling, 'expect epic-verify:pass sibling').toBeDefined(); + expect(failSibling, 'expect epic-verify:fail halt-sibling').toBeDefined(); + + for (const sibling of [passSibling!, failSibling!]) { + expect(sibling.inputs).toEqual(['epic:epic-1:verify:reported']); + } + + // Pass sibling emits to the epic done place (no depSignals here — epic-1 has no epic dependents). + expect(enumerateCandidateOutputs(passSibling!)).toEqual(new Set(['epic:epic-1:done'])); + + // Fail halt-sibling emits to the epic halted place (FE-761 Slice 2a: + // halted-as-place — halt is now a structural place-token, not a ctx side + // effect alone). + expect(enumerateCandidateOutputs(failSibling!)).toEqual(new Set(['epic:epic-1:halted'])); + + // Branching descriptor fields are gone from the producer. + const producerHandler = producer!.handler; + if (producerHandler.kind === 'verify-epic') { + expect(producerHandler).not.toHaveProperty('onPassOutputs'); + } + }); + + it('assess-semantic decomposes into producer + 2 sibling passthroughs (satisfied / rejected)', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + + const producer = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic:complete'); + expect(producer).toBeDefined(); + expect(producer!.handler.kind).toBe('assess-semantic'); + + // Producer emits to intermediate + budget place; no direct satisfied/rejected routes. + const producerOutputs = enumerateCandidateOutputs(producer!); + expect(producerOutputs).toEqual( + new Set([ + 'slice:slice-1:assess-semantic:reported', + 'slice:slice-1:semantic-budget', + 'slice:slice-1:halted', + ]), + ); + + const satSibling = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic:satisfied'); + const rejSibling = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic:rejected'); + expect(satSibling, 'expect assess-semantic:satisfied sibling').toBeDefined(); + expect(rejSibling, 'expect assess-semantic:rejected sibling').toBeDefined(); + + for (const sibling of [satSibling!, rejSibling!]) { + expect(sibling.inputs).toEqual(['slice:slice-1:assess-semantic:reported']); + expect(sibling.handler.kind).toBe('sibling-passthrough'); + } + + expect(enumerateCandidateOutputs(satSibling!)).toEqual(new Set(['slice:slice-1:semantic-satisfied'])); + expect(enumerateCandidateOutputs(rejSibling!)).toEqual(new Set(['slice:slice-1:needs-more'])); + + const producerHandler = producer!.handler; + if (producerHandler.kind === 'assess-semantic') { + expect(producerHandler).not.toHaveProperty('onSatisfied'); + expect(producerHandler).not.toHaveProperty('onRejected'); + } + }); +}); + +// --------------------------------------------------------------------------- +// FE-761 Slice 2a: halted-as-place +// +// Halt paths (retry exhaustion in run-tests, rework exhaustion in +// assess-semantic, verify-epic failure) now emit a halt token to a +// `slice::halted` or `epic::halted` place instead of only +// mutating ctx.halted in a fire closure. This makes halt observable at the +// topology level and is a precondition for Slice 2b's dispatch/complete +// async refactor (which retires ctx.halted entirely). +// --------------------------------------------------------------------------- + +describe('FE-761 Slice 2a: halted-as-place', () => { + it('declares slice::halted place for every slice', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + expect(blueprint.places).toContain('slice:slice-a:halted'); + expect(blueprint.places).toContain('slice:slice-b:halted'); + }); + + it('declares epic::halted place for every epic with verification', () => { + const verifyPlan: Plan = { + epics: [ + { + id: 'epic-1', + summary: 'E', + depends_on: [], + verification: [{ kind: 'integration-test', target: 'it.test.ts' }], + }, + ], + slices: [ + { + id: 'slice-1', + epic_id: 'epic-1', + definition: 'D', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't' }], + }, + ], + }; + const blueprint = compileTopology(verifyPlan, { maxRetries: 3 }); + expect(blueprint.places).toContain('epic:epic-1:halted'); + }); + + it('does not declare epic::halted for epics without verification', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + expect(blueprint.places).not.toContain('epic:epic-1:halted'); + }); }); diff --git a/src/orchestrator/src/types.ts b/src/orchestrator/src/types.ts index bfbca2b6..ae0affe1 100644 --- a/src/orchestrator/src/types.ts +++ b/src/orchestrator/src/types.ts @@ -137,10 +137,15 @@ export interface Orchestrator { // Mutable run context — orchestrator-execution bookkeeping // --------------------------------------------------------------------------- +/** + * FE-761 Slice 2b: `halted` / `haltReason` retired. Halt is now observable + * via the petri-net's `:halted` place tokens (see `PetriNet.hasHaltToken()`), + * and the halt reason is carried on the halt token itself + * (`Token.haltReason`). The engine derives both from the net rather than + * mutating ctx in a fire closure. + */ export type RunCtx = { reportIds: string[]; sliceOutcomes: Map; epicOutcomes: Map; - halted: boolean; - haltReason?: string; };