diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 6201ee42..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,156 +0,0 @@ - - -# petri-semantic-lanes — scope cards - -## Card 1: Two-lane subnet with semantic completion gate - -**Status:** next - -### Target Behavior - -The compiled slice subnet enforces a two-lane terminal join: `return-done` is unreachable unless both mechanical verification (`done-spec`) and semantic assessment (`semantic-satisfied`) have produced tokens. - -### Boundary Crossings - -``` -→ types.ts (add 'assess-semantic' to action vocabulary) -→ net-compiler.ts (add semantic places + assess-semantic transition + terminal join) -→ engine-contract.test.ts (update call-order assertions, add semantic-gate scenario) -→ petri-net.ts (no change — interpreter is topology-agnostic) -→ engine-petri.ts / engine-proc.ts (no change — thin wrappers) -``` - -### Risks and Assumptions - -``` -- RISK: All existing contract tests check call-order sequences that will gain an - assess-semantic step → every test with call-order assertions needs updating. - → MITIGATION: Mechanical change — add the step to expected sequences. The - fake factory already uses Record so adding a new key - is trivial. - -- RISK: Semantic assessment always passes in fakes — the topological constraint - is real but the assessment itself is a no-op until real oracles land. - → MITIGATION: Add one contract test where assess-semantic fails → slice halts. - This proves the gate is load-bearing, not decorative. - -- ASSUMPTION: A single assess-semantic action per slice is sufficient for Phase 1. - The spec doc shows multiple semantic transitions (AssessOracleSatisfaction, - AssessDesignExercised, AssessIntentEstablished), but those can be sub-steps - of one assessment action in this slice; the net template can refine later. - → VALIDATE: The terminal join enforces the gate; internal decomposition of - semantic assessment is additive, not structural. -``` - -### Acceptance Criteria - -``` -✓ semantic-places — Compiled subnet per slice includes `semantic-gate` and - `semantic-satisfied` places. Adapter test confirms updated place count. - -✓ assess-semantic-transition — New transition `{sliceId}:assess-semantic` - consumes `done-spec` + `semantic-gate` and produces `semantic-satisfied` - (on pass) or routes to `needs-more` (on fail, forcing another TDD cycle). - -✓ terminal-join — `return-done` transition consumes `semantic-satisfied` - instead of `done-spec`. PlanDoneAccepted (= `completed` place) is - topologically unreachable without semantic satisfaction. - -✓ assess-semantic-action — `assess-semantic` key added to ActionHandlers. - Fake factory provides a default that always returns { satisfied: true }. - -✓ contract-tests-updated — All existing contract test call-order assertions - include the new assess-semantic step. All 26 tests pass. - -✓ semantic-gate-fail-test — New contract test: assess-semantic returns - { satisfied: false } → slice re-enters TDD loop. If it keeps failing, - slice halts. - -✓ adapter-test-updated — Net shape adapter tests updated for new place - and transition counts. -``` - -### Verification Approach - -``` -- Inner: npm run verify — contract tests (26 existing updated + 1–2 new), - adapter tests updated, lint + type-check + build. -- Middle: n/a (no product behavior change) -- Outer: n/a -``` - -### Implementation notes - -1. Add `assess-semantic` to fake factory (returns `{ satisfied: true }` by default). -2. Add `semantic-gate` and `semantic-satisfied` places to compiler template. -3. Seed `semantic-gate` with a token when slice starts. -4. Add `assess-semantic` transition: consumes `done-spec` + `semantic-gate`, - calls `actions['assess-semantic']`, routes to `semantic-satisfied` or `needs-more`. -5. Change `return-done` inputs from `[done-spec]` to `[semantic-satisfied]`. -6. Update all contract test call-order expectations. -7. Add semantic-gate-fail contract test. -8. Update adapter test place/transition counts. - -## Card 2: TransitionContract type - -**Status:** queued - -### Objective - -Each transition in the compiled net carries typed metadata (`TransitionContract`) describing its kind, lane, actor, and guard — enabling the interpreter and future event model to distinguish mechanical from semantic transitions without inspecting transition IDs. - -### Acceptance Criteria - -``` -✓ TransitionContract type defined in petri-net.ts with fields: kind - ('mechanical' | 'semantic' | 'structural'), lane, actor, guard description. - -✓ TransitionDef gains an optional `contract` field. - -✓ Compiler populates contract metadata for all transitions it creates. - -✓ Adapter test asserts that mechanical-lane transitions have kind='mechanical' - and semantic-lane transitions have kind='semantic'. - -✓ No behavioral change — interpreter ignores contract metadata for now. - -✓ All existing tests pass. -``` - -### Verification Approach - -``` -- Inner: npm run verify — type-check + adapter tests for contract metadata. -``` - -## Card 3: §7 event vocabulary - -**Status:** queued - -### Objective - -The interpreter emits structured events from the spec §7 vocabulary (`transition_fired`, `oracle_passed`, `task_dispatched`, `net_deadlocked`, …) as each transition fires, providing a durable replayable record for audit, visualization, and future graph reconciliation. - -### Acceptance Criteria - -``` -✓ Event type defined with spec §7 vocabulary fields (event kind, transition id, - consumed/produced places, timestamp, contract metadata). - -✓ PetriNet.run() accepts an optional event sink and emits transition_fired - events on each firing. - -✓ Net deadlock (no enabled transition, not halted) emits net_deadlocked. - -✓ Contract tests can optionally capture and assert event sequences. - -✓ Existing tests pass (event sink is optional, defaults to no-op). -``` - -### Verification Approach - -``` -- Inner: npm run verify — event sink tests, contract tests with event capture. -``` diff --git a/memory/PLAN.md b/memory/PLAN.md index d11885cc..258879f8 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -28,10 +28,13 @@ 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. +### Recently Completed + +- `petri-semantic-lanes` (FE-738) — two-lane subnet, compiler topology/wiring split, engine factory, semantic rework budget, §7 events. PR #148. Criterion (5) stale-graph deferred → `petri-graph-compilation`. + ### Next -1. `petri-semantic-lanes` — mechanical + semantic two-lane subnet template + §7 event vocabulary; first frontier where petri diverges meaningfully from proc. Follows `orchestrator-poc` Phase 0. -2. `petri-parallel-execution` — parallel firing, shared resource pools, worktree-per-slice coordination; the categorical break where petri earns its complexity. Decision gate: if petri doesn't beat proc on wall clock, pause petri investment. Follows `petri-semantic-lanes`. +1. `petri-parallel-execution` — parallel firing, shared resource pools, worktree-per-slice coordination; the categorical break where petri earns its complexity. Decision gate: if petri doesn't beat proc on wall clock, pause petri investment. Follows `petri-semantic-lanes`. 3. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation. 4. `changeset-ledger` — Track 4 of the runtime umbrella; parallel with Track 2; semantic history spine needed before canonical proposal acceptance, direct-edit atomicity, and productized scenario options. 5. `chat-context-provision` — Track 5 of the runtime umbrella recast as transcript-first context; can proceed against chat/turn once secondary-chat entry/anchor shape is settled. @@ -80,16 +83,15 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### petri-semantic-lanes - **Name:** Petri semantic lanes — mechanical + semantic two-lane subnet template -- **Linear:** unassigned (create under umbrella H-6476) +- **Linear:** FE-738 - **Kind:** structural -- **Status:** not-started +- **Status:** done (criterion 5 deferred → `petri-graph-compilation`) - **Objective:** Extend the extracted NetCompiler to produce a two-lane subnet template per slice: a mechanical lane (dispatch, artifact production, test execution, verification) and a semantic lane (oracle satisfaction, design exercise, intent establishment, completion claim review). Add the spec §7 structured event vocabulary (`transition_fired`, `oracle_passed`, `graph_revision_stale`, `semantic_review_requested`, `completion_claim_accepted`, `status_projection_suggested`, `net_deadlocked`, …) as the interpreter's durable event model. `TransitionContract` type (spec §6) governs each transition's kind, actor, guard, action binding, and emitted events. Mechanical transitions produce candidate evidence; semantic transitions judge that evidence against graph-derived requirements. `PlanDoneAccepted` is reachable only after both lanes complete. - **Why now / unlocks:** First frontier where the Petri engine models a distinction the proc engine cannot express topologically: mechanical completion ≠ semantic completion. Unblocks Phase 2 parallel firing (lanes can fire concurrently) and Phase 3 graph compilation (semantic lane structure maps to relation-policy gates). Without this, the engine remains a serial task runner with token-shaped bookkeeping. -- **Acceptance:** (1) Compiled subnet has distinct mechanical and semantic places/transitions. (2) `PlanDoneAccepted` is unreachable unless both `VerifyPassed` and `OracleSatisfied` (or equivalent semantic tokens) are present. (3) Event log records §7 vocabulary events per transition firing. (4) `TransitionContract` type covers kind, actor, guard, and emits. (5) Stale-graph detection routes to reconciliation or context rebuild, not a dead-end. (6) Existing contract test suite passes with both engines. -- **Verification:** Contract tests extended with semantic-lane scenarios (happy-path Prototype A, stale-graph Prototype B, missing-oracle Prototype C from spec §10). Adapter test for two-lane net shape. Event-log assertions for §7 vocabulary. +- **Acceptance:** (1) ✅ Compiled subnet has distinct mechanical and semantic places/transitions. (2) ✅ `PlanDoneAccepted` is unreachable unless both `VerifyPassed` and `OracleSatisfied` (or equivalent semantic tokens) are present. (3) ✅ Event log records §7 vocabulary events per transition firing. (4) ✅ `TransitionContract` type covers kind, actor, guard, and emits. (5) **Deferred → `petri-graph-compilation`**: stale-graph detection requires `GraphRevisionCurrent` tokens and graph revision semantics from `intent-graph-semantics` (FE-700); the current Plan-from-YAML substrate has no mutable graph revision to detect staleness against. (6) ✅ Existing contract test suite passes. (7) ✅ `compileTopology(plan, policy) → NetBlueprint` is pure (no runtime refs); `wireHandlers(blueprint, input, ctx) → PetriNet` attaches closures. (8) ✅ `createOrchestrator(policy)` factory replaces identical engine classes. (9) ✅ `RunCtx` lives in `types.ts`, not compiler. (10) ✅ Semantic rework budget (`semantic-budget` place) prevents infinite rework loops. (11) ✅ `HandlerDescriptor` discriminated union describes each transition's routing recipe declaratively. +- **Verification:** Contract tests extended with semantic-lane scenarios (happy-path Prototype A, stale-graph Prototype B, missing-oracle Prototype C from spec §10). Adapter test for two-lane net shape via `compileTopology` (pure topology, no runtime bindings needed). Event-log assertions for §7 vocabulary. Semantic rework exhaustion contract test. - **Traceability:** Requirements 46–50; spec §2 (layer split), §4 (canonical slice-net), §6 (transition contracts), §7 (event model), §8 (failure-mode nets), §10 (prototypes A–C). - **Design docs:** `docs/next/architecture/plan-graph-petri-orchestration.md`; `docs/design/orchestrator.md`; umbrella H-6476. -- **Current execution pointer:** `memory/CARDS.md` — Card 1 (two-lane subnet with semantic completion gate). ### petri-parallel-execution diff --git a/src/orchestrator/src/cook-cli.test.ts b/src/orchestrator/src/cook-cli.test.ts index 8e8af74c..e4c961fa 100644 --- a/src/orchestrator/src/cook-cli.test.ts +++ b/src/orchestrator/src/cook-cli.test.ts @@ -6,14 +6,14 @@ describe('parseCookArgs', () => { it('parses dir only', () => { const opts = parseCookArgs(['./fixtures/txt']); expect(opts.dir).toContain('fixtures/txt'); - expect(opts.engine).toBe('petri'); + expect(opts.policy).toBe('serial'); expect(opts.maxRetries).toBe(3); expect(opts.verbose).toBe(false); }); - it('parses --engine=petri', () => { - const opts = parseCookArgs(['./f', '--engine=petri']); - expect(opts.engine).toBe('petri'); + it('parses --policy=serial', () => { + const opts = parseCookArgs(['./f', '--policy=serial']); + expect(opts.policy).toBe('serial'); }); it('parses --max-retries=5', () => { @@ -22,11 +22,11 @@ describe('parseCookArgs', () => { }); it('throws on missing dir', () => { - expect(() => parseCookArgs(['--engine=proc'])).toThrow('Usage'); + expect(() => parseCookArgs(['--policy=serial'])).toThrow('Usage'); }); - it('throws on unknown engine', () => { - expect(() => parseCookArgs(['./f', '--engine=unknown'])).toThrow('Unknown engine'); + it('throws on unknown policy', () => { + expect(() => parseCookArgs(['./f', '--policy=unknown'])).toThrow('Unknown policy'); }); it('parses --verbose', () => { diff --git a/src/orchestrator/src/cook-cli.ts b/src/orchestrator/src/cook-cli.ts index 53b0f7fc..add26b83 100644 --- a/src/orchestrator/src/cook-cli.ts +++ b/src/orchestrator/src/cook-cli.ts @@ -1,36 +1,35 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { PetriOrchestrator } from './engine-petri.js'; -import { ProceduralOrchestrator } from './engine-proc.js'; +import { createOrchestrator } from './engine.js'; import { FileReportSink } from './file-report-sink.js'; +import type { FiringPolicy } from './petri-net.js'; import { createPiActions } from './pi-actions.js'; import { loadPlan } from './plan-loader.js'; import { BunTestRunner } from './test-runner.js'; -import type { Orchestrator } from './types.js'; import { createWorktree } from './worktree.js'; export type CookOptions = { dir: string; - engine: 'proc' | 'petri'; + policy: FiringPolicy; maxRetries: number; verbose: boolean; }; export function parseCookArgs(args: string[]): CookOptions { let dir = ''; - let engine: 'proc' | 'petri' = 'petri'; + let policy: FiringPolicy = 'serial'; let maxRetries = 3; let verbose = false; for (let i = 0; i < args.length; i++) { const arg = args[i]!; - if (arg.startsWith('--engine=')) { + if (arg.startsWith('--policy=')) { const val = arg.split('=')[1]!; - if (val !== 'proc' && val !== 'petri') { - throw new Error(`Unknown engine: ${val}. Use proc or petri.`); + if (val !== 'serial') { + throw new Error(`Unknown policy: ${val}. Use serial.`); } - engine = val; + policy = val; } else if (arg.startsWith('--max-retries=')) { const parsed = Number.parseInt(arg.split('=')[1]!, 10); if (!Number.isFinite(parsed) || parsed < 0) { @@ -45,10 +44,10 @@ export function parseCookArgs(args: string[]): CookOptions { } if (!dir) { - throw new Error('Usage: brunch cook [--engine=proc|petri] [--max-retries=N] [--verbose]'); + throw new Error('Usage: brunch cook [--policy=serial] [--max-retries=N] [--verbose]'); } - return { dir: resolve(dir), engine, maxRetries, verbose }; + return { dir: resolve(dir), policy, maxRetries, verbose }; } function fmtDuration(ms: number): string { @@ -84,7 +83,7 @@ export async function runCook(opts: CookOptions): Promise { console.error(''); console.error(` brunch cook`); console.error(` ──────────────────────────────────────`); - console.error(` engine ${opts.engine}`); + console.error(` policy ${opts.policy}`); console.error(` plan ${epicCount} epics, ${sliceCount} slices`); console.error(` retries ${opts.maxRetries}`); console.error(` worktree ${worktreeDir}`); @@ -94,8 +93,7 @@ export async function runCook(opts: CookOptions): Promise { const reports = new FileReportSink(reportsPath); const testRunner = new BunTestRunner(); - const engine: Orchestrator = - opts.engine === 'petri' ? new PetriOrchestrator() : new ProceduralOrchestrator(); + const engine = createOrchestrator(opts.policy); const runStart = Date.now(); const actions = createPiActions({ verbose: opts.verbose, runStart }); diff --git a/src/orchestrator/src/engine-contract.test.ts b/src/orchestrator/src/engine-contract.test.ts index c0e99c8f..23b49b32 100644 --- a/src/orchestrator/src/engine-contract.test.ts +++ b/src/orchestrator/src/engine-contract.test.ts @@ -1,20 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { PetriOrchestrator } from './engine-petri.js'; -import { ProceduralOrchestrator } from './engine-proc.js'; -import { compilePlan } from './net-compiler.js'; -import type { RunCtx } from './net-compiler.js'; +import { createOrchestrator } from './engine.js'; +import { compilePlan, compileTopology } from './net-compiler.js'; +import type { NetEvent } from './petri-net.js'; import { InMemoryReportSink } from './report-sink.js'; -import type { ActionContext, ActionHandlers, OrchestratorInput, Plan, TestRunner } from './types.js'; +import type { ActionContext, ActionHandlers, OrchestratorInput, Plan, RunCtx, TestRunner } from './types.js'; // --------------------------------------------------------------------------- // Shared engine list for parameterized tests // --------------------------------------------------------------------------- -const engines = [ - { name: 'procedural', create: () => new ProceduralOrchestrator() }, - { name: 'petri', create: () => new PetriOrchestrator() }, -] as const; +const engines = [{ name: 'serial', create: () => createOrchestrator('serial') }] as const; // --------------------------------------------------------------------------- // Reusable fake factory — per-test closures instead of module-level state @@ -24,14 +20,17 @@ function createFakes(opts?: { evalSequence?: boolean[]; // sequence of done values for evaluate-done testRunResults?: boolean[]; // sequence of passed values for test runner verifyEpicResult?: boolean; // result of verify-epic + semanticResults?: boolean[]; // sequence of satisfied values for assess-semantic throwOnAction?: string; // action name that throws }) { const callOrder: string[] = []; const reports = new InMemoryReportSink(); let evalIdx = 0; let testRunIdx = 0; + let semanticIdx = 0; const evalSeq = opts?.evalSequence ?? [false, true]; // default: NO then YES const testSeq = opts?.testRunResults ?? [true]; // default: pass + const semanticSeq = opts?.semanticResults ?? [true]; // default: satisfied const actions: ActionHandlers = { 'evaluate-done': async (ctx: ActionContext) => { @@ -96,6 +95,23 @@ function createFakes(opts?: { callOrder.push(`${ctx.epic.id}:verify-epic:${passed ? 'PASS' : 'FAIL'}`); return id; }, + 'assess-semantic': async (ctx: ActionContext) => { + if (opts?.throwOnAction === 'assess-semantic') throw new Error('assess-semantic failed'); + const satisfied = semanticSeq[semanticIdx % semanticSeq.length]!; + semanticIdx++; + const id = `rpt-sem-${ctx.slice.id}-${semanticIdx}`; + reports.append({ + id, + ts: new Date().toISOString(), + epicId: ctx.epic.id, + sliceId: ctx.slice.id, + actor: 'semantic-assessor', + event: 'semantic-assessed', + payload: { satisfied }, + }); + callOrder.push(`${ctx.slice.id}:assess-semantic:${satisfied ? 'PASS' : 'FAIL'}`); + return id; + }, }; const testRunner: TestRunner = { @@ -180,6 +196,7 @@ describe('Engine contract test #1 — single epic, single slice, happy path', () 'slice-1:write-code', 'run-tests:pass', 'slice-1:evaluate-done:YES', + 'slice-1:assess-semantic:PASS', ]); }); @@ -197,6 +214,7 @@ describe('Engine contract test #1 — single epic, single slice, happy path', () expect(events).toContain('eval-done'); expect(events).toContain('tests-written'); expect(events).toContain('code-written'); + expect(events).toContain('semantic-assessed'); }); }); } @@ -234,10 +252,7 @@ const depPlan: Plan = { }; describe('Engine contract test #2 — intra-epic slice dependencies', () => { - const engines = [ - { name: 'procedural', create: () => new ProceduralOrchestrator() }, - { name: 'petri', create: () => new PetriOrchestrator() }, - ] as const; + const engines = [{ name: 'serial', create: () => createOrchestrator('serial') }] as const; for (const { name, create } of engines) { describe(name, () => { @@ -307,6 +322,20 @@ describe('Engine contract test #2 — intra-epic slice dependencies', () => { }); return id; }, + 'assess-semantic': async (ctx: ActionContext) => { + const id = `rpt-sem-${ctx.slice.id}`; + reports.append({ + id, + ts: new Date().toISOString(), + epicId: ctx.epic.id, + sliceId: ctx.slice.id, + actor: 'semantic-assessor', + event: 'semantic-assessed', + payload: { satisfied: true }, + }); + sliceCallOrder.push(`${ctx.slice.id}:assess-semantic:PASS`); + return id; + }, }; const depTestRunner: TestRunner = { @@ -579,13 +608,155 @@ describe('Engine contract test #9 — action handler throws', () => { } }); +// --------------------------------------------------------------------------- +// Contract test #10 — semantic gate rejects → rework loop +// --------------------------------------------------------------------------- + +describe('Engine contract test #10 — semantic gate rejects then accepts', () => { + for (const { name, create } of engines) { + it(`${name}: assess-semantic fails once then passes → extra TDD cycle`, async () => { + // eval: NO, YES (first TDD cycle completes mechanically), + // semantic: FAIL → needs-more → write-tests → write-code → run-tests + // → spec-ready → eval: YES (second mechanical done), + // semantic: PASS → done + const fakes = createFakes({ + evalSequence: [false, true, true], + semanticResults: [false, true], + }); + const result = await create().run({ + plan: simplePlan, + worktreeDir: '/tmp/f', + actions: fakes.actions, + reports: fakes.reports, + testRunner: fakes.testRunner, + policy: { maxRetries: 3 }, + }); + + expect(result.status).toBe('completed'); + // Should have two assess-semantic calls: first FAIL, then PASS + const semantics = fakes.callOrder.filter((c) => c.includes('assess-semantic')); + expect(semantics).toEqual(['slice-1:assess-semantic:FAIL', 'slice-1:assess-semantic:PASS']); + // Two TDD cycles (2 write-tests calls) + const writeTests = fakes.callOrder.filter((c) => c.includes('write-tests')); + expect(writeTests.length).toBe(2); + }); + } +}); + +// --------------------------------------------------------------------------- +// Contract test #11 — semantic rework exhaustion +// --------------------------------------------------------------------------- + +describe('Engine contract test #11 — semantic rework exhaustion halts', () => { + for (const { name, create } of engines) { + it(`${name}: assess-semantic always fails → halted after maxSemanticReworks`, async () => { + const fakes = createFakes({ + evalSequence: [false, true], // NO then YES (repeated) + semanticResults: [false], // always rejects + }); + const result = await create().run({ + plan: simplePlan, + worktreeDir: '/tmp/f', + actions: fakes.actions, + reports: fakes.reports, + testRunner: fakes.testRunner, + policy: { maxRetries: 3, maxSemanticReworks: 2 }, + }); + + expect(result.status).toBe('halted'); + expect(result.slices).toEqual([{ sliceId: 'slice-1', status: 'halted' }]); + expect(result.reason).toContain('semantic'); + // Should have exactly maxSemanticReworks + 1 semantic assessments + const semantics = fakes.callOrder.filter((c) => c.includes('assess-semantic')); + expect(semantics.length).toBe(3); // 0, 1, 2 → exhausted at 2 + }); + } +}); + // --------------------------------------------------------------------------- // Adapter test — compiled net shape for simplePlan // --------------------------------------------------------------------------- -describe('Adapter: compiled net shape', () => { +describe('Adapter: compiled net shape (topology-only — no runtime bindings)', () => { it('simplePlan compiles to expected place and transition counts', () => { - const reports = new InMemoryReportSink(); + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + + // simplePlan: 1 epic, 1 slice (no deps) + // Epic places: epic:epic-1:done = 1 + // Mechanical places: spec-ready, test-agent, code-agent, failing-tests, + // untested-code, needs-more, done-spec, completed, eligible, + // retry-budget = 10 + // 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, + // slice-1:return-done, epic-complete:epic-1 + // Total: 8 + expect(blueprint.transitions.length).toBe(8); + }); + + it('simplePlan transitions carry correct contract metadata', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const transitions = blueprint.transitions; + + // Mechanical-lane transitions + const mechanical = transitions.filter((t) => t.contract.lane === 'mechanical'); + expect(mechanical.length).toBeGreaterThanOrEqual(5); // ready, evaluate, write-tests, write-code, run-tests + for (const t of mechanical) { + if (t.contract.kind !== 'structural') { + expect(t.contract.kind).toBe('mechanical'); + } + } + + // 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')); + expect(assessSemantic?.contract.kind).toBe('semantic'); + expect(assessSemantic?.contract.actor).toBe('semantic-assessor'); + }); + + it('depPlan compiles with additional dep-signal places and transitions', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + + // depPlan: 1 epic, 2 slices (slice-b depends on slice-a) + // Epic places: epic:epic-1:done = 1 + // Slice-a places: 12 (8 standard + eligible + retry-budget + semantic-budget + semantic-satisfied) + // Slice-b places: 12 (8 standard + eligible + retry-budget + semantic-budget + semantic-satisfied) + // Dep-signal places: slice:slice-a:dep-signal:slice-b = 1 + // Total: 26 + expect(blueprint.places.length).toBe(26); + + // 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 + // epic-complete:epic-1 = 1 + // Total: 15 + expect(blueprint.transitions.length).toBe(15); + }); + + 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'); + expect(kinds).toContain('action'); + expect(kinds).toContain('run-tests'); + expect(kinds).toContain('assess-semantic'); + expect(kinds).toContain('complete-slice'); + expect(kinds).toContain('complete-epic'); + }); +}); + +// --------------------------------------------------------------------------- +// Adapter test — §7 event vocabulary +// --------------------------------------------------------------------------- + +describe('Adapter: §7 event vocabulary', () => { + it('simplePlan happy path emits transition_fired events for each transition', async () => { + const fakes = createFakes(); const ctx: RunCtx = { reportIds: [], sliceOutcomes: new Map(), @@ -596,32 +767,42 @@ describe('Adapter: compiled net shape', () => { const input: OrchestratorInput = { plan: simplePlan, worktreeDir: '/tmp/fake', - actions: createFakes().actions, - reports, - testRunner: createFakes().testRunner, + actions: fakes.actions, + reports: fakes.reports, + testRunner: fakes.testRunner, policy: { maxRetries: 3 }, }; const net = compilePlan(input, ctx); - - // simplePlan: 1 epic, 1 slice (no deps) - // Epic places: epic:epic-1:done = 1 - // Slice places: spec-ready, test-agent, code-agent, failing-tests, - // untested-code, needs-more, done-spec, completed, eligible, - // retry-budget = 10 - // Total places: 11 - expect(net.placeCount).toBe(11); - - // Transitions: - // slice-ready:slice-1, slice-1:evaluate, slice-1:write-tests, - // slice-1:write-code, slice-1:run-tests, slice-1:return-done, - // epic-complete:epic-1 - // Total: 7 - expect(net.transitionCount).toBe(7); + const events: NetEvent[] = []; + await net.run('serial', () => ctx.halted, { 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'); + expect(fired.length).toBeGreaterThan(0); + + // 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'); + expect(ids).toContain('slice-1:return-done'); + expect(ids).toContain('epic-complete:epic-1'); + + // Each fired event carries contract metadata + for (const e of fired) { + expect(e.contract).toBeDefined(); + expect(e.consumed).toBeDefined(); + expect(e.produced).toBeDefined(); + } + + // No halt or false-deadlock events (happy path) + expect(events.filter((e) => e.kind === 'net_halted').length).toBe(0); + expect(events.filter((e) => e.kind === 'net_deadlocked').length).toBe(0); }); - it('depPlan compiles with additional dep-signal places and transitions', () => { - const reports = new InMemoryReportSink(); + it('retry exhaustion emits net_deadlocked', async () => { + const fakes = createFakes({ testRunResults: [false] }); const ctx: RunCtx = { reportIds: [], sliceOutcomes: new Map(), @@ -630,29 +811,20 @@ describe('Adapter: compiled net shape', () => { halted: false, }; const input: OrchestratorInput = { - plan: depPlan, + plan: simplePlan, worktreeDir: '/tmp/fake', - actions: createFakes().actions, - reports, - testRunner: createFakes().testRunner, - policy: { maxRetries: 3 }, + actions: fakes.actions, + reports: fakes.reports, + testRunner: fakes.testRunner, + policy: { maxRetries: 1 }, }; const net = compilePlan(input, ctx); + const events: NetEvent[] = []; + await net.run('serial', () => ctx.halted, { emit: (e) => events.push(e) }); - // depPlan: 1 epic, 2 slices (slice-b depends on slice-a) - // Epic places: epic:epic-1:done = 1 - // Slice-a places: 10 (8 standard + eligible + retry-budget) - // Slice-b places: 10 (8 standard + eligible + retry-budget) - // Dep-signal places: slice:slice-a:dep-signal:slice-b = 1 - // Total: 22 - expect(net.placeCount).toBe(22); - - // Transitions: - // slice-a: slice-ready, evaluate, write-tests, write-code, run-tests, return-done = 6 - // slice-b: slice-ready (with dep gate), evaluate, write-tests, write-code, run-tests, return-done = 6 - // epic-complete:epic-1 = 1 - // Total: 13 - expect(net.transitionCount).toBe(13); + // Should have a net_halted event (ctx.halted becomes true after retry exhaustion) + const halted = events.filter((e) => e.kind === 'net_halted'); + expect(halted.length).toBe(1); }); }); diff --git a/src/orchestrator/src/engine-petri.ts b/src/orchestrator/src/engine-petri.ts deleted file mode 100644 index 5157e0d4..00000000 --- a/src/orchestrator/src/engine-petri.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { runCompiledOrchestrator } from './engine-run.js'; -import type { Orchestrator, OrchestratorInput, OrchestratorResult } from './types.js'; - -// --------------------------------------------------------------------------- -// PetriOrchestrator — compiled net; serial in Phase 0, parallel in Phase 2. -// --------------------------------------------------------------------------- - -export class PetriOrchestrator implements Orchestrator { - run(input: OrchestratorInput): Promise { - // Phase 2: switch to 'parallel' once the interpreter supports it. - return runCompiledOrchestrator(input, 'serial'); - } -} diff --git a/src/orchestrator/src/engine-proc.ts b/src/orchestrator/src/engine-proc.ts deleted file mode 100644 index 4d4709da..00000000 --- a/src/orchestrator/src/engine-proc.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { runCompiledOrchestrator } from './engine-run.js'; -import type { Orchestrator, OrchestratorInput, OrchestratorResult } from './types.js'; - -// --------------------------------------------------------------------------- -// ProceduralOrchestrator — compiled net with serial firing policy. -// Phase 2 keeps proc serial while petri gains parallel concurrency. -// --------------------------------------------------------------------------- - -export class ProceduralOrchestrator implements Orchestrator { - run(input: OrchestratorInput): Promise { - return runCompiledOrchestrator(input, 'serial'); - } -} diff --git a/src/orchestrator/src/engine-run.ts b/src/orchestrator/src/engine-run.ts deleted file mode 100644 index 5542f9c6..00000000 --- a/src/orchestrator/src/engine-run.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { compilePlan } from './net-compiler.js'; -import type { RunCtx } from './net-compiler.js'; -import type { FiringPolicy } from './petri-net.js'; -import type { OrchestratorInput, OrchestratorResult } from './types.js'; - -/** Shared orchestrator run loop — compile plan, interpret net with the given policy. */ -export async function runCompiledOrchestrator( - input: OrchestratorInput, - policy: FiringPolicy, -): Promise { - const ctx: RunCtx = { - reportIds: [], - sliceOutcomes: new Map(), - epicOutcomes: new Map(), - halted: false, - }; - - try { - const net = compilePlan(input, ctx); - await net.run(policy, () => ctx.halted); - } catch (err) { - return { - status: 'halted', - reason: err instanceof Error ? err.message : String(err), - reports: ctx.reportIds, - epics: input.plan.epics.map( - (e) => ctx.epicOutcomes.get(e.id) ?? { epicId: e.id, status: 'halted' as const }, - ), - slices: input.plan.slices.map( - (s) => ctx.sliceOutcomes.get(s.id) ?? { sliceId: s.id, status: 'halted' as const }, - ), - }; - } - - // Fill in any slices/epics not yet in outcomes (e.g. never reached) - 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'; - } - } - 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'; - } - } - - return { - status: ctx.halted ? 'halted' : 'completed', - reason: ctx.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/engine.ts b/src/orchestrator/src/engine.ts new file mode 100644 index 00000000..c0ed4518 --- /dev/null +++ b/src/orchestrator/src/engine.ts @@ -0,0 +1,64 @@ +import { compileTopology, wireHandlers } from './net-compiler.js'; +import type { FiringPolicy } from './petri-net.js'; +import type { Orchestrator, OrchestratorInput, OrchestratorResult, RunCtx } from './types.js'; + +// --------------------------------------------------------------------------- +// createOrchestrator — single factory. Two-pass compilation pipeline: +// 1. compileTopology(plan, policy) → NetBlueprint (pure data) +// 2. wireHandlers(blueprint, input, ctx) → PetriNet (fire closures) +// --------------------------------------------------------------------------- + +export function createOrchestrator(firingPolicy: FiringPolicy): Orchestrator { + return { + async run(input: OrchestratorInput): Promise { + const ctx: RunCtx = { + reportIds: [], + sliceOutcomes: new Map(), + epicOutcomes: new Map(), + halted: false, + }; + + try { + const blueprint = compileTopology(input.plan, input.policy); + const net = wireHandlers(blueprint, input, ctx); + await net.run(firingPolicy, () => ctx.halted); + } catch (err) { + return { + status: 'halted', + reason: err instanceof Error ? err.message : String(err), + reports: ctx.reportIds, + epics: input.plan.epics.map( + (e) => ctx.epicOutcomes.get(e.id) ?? { epicId: e.id, status: 'halted' as const }, + ), + slices: input.plan.slices.map( + (s) => ctx.sliceOutcomes.get(s.id) ?? { sliceId: s.id, status: 'halted' as const }, + ), + }; + } + + // Fill in any slices/epics not yet in outcomes (e.g. never reached) + 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'; + } + } + 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'; + } + } + + return { + status: ctx.halted ? 'halted' : 'completed', + reason: ctx.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 new file mode 100644 index 00000000..70286e65 --- /dev/null +++ b/src/orchestrator/src/net-blueprint.ts @@ -0,0 +1,127 @@ +// --------------------------------------------------------------------------- +// NetBlueprint — pure declarative net shape, no closures or runtime refs. +// Produced by compileTopology(); consumed by wireHandlers(). +// --------------------------------------------------------------------------- + +import type { TransitionContract } from './petri-net.js'; + +// --------------------------------------------------------------------------- +// Token identity for initial token seeding and output routing +// --------------------------------------------------------------------------- + +export type TokenSeed = { + sliceId: string; + epicId: string; + retryCount?: number; + reworkCount?: number; +}; + +// --------------------------------------------------------------------------- +// Handler descriptors — declarative recipe the wirer interprets +// --------------------------------------------------------------------------- + +/** Structural passthrough — fixed outputs, no action call. */ +type PassthroughDescriptor = { + kind: 'passthrough'; + outputs: { place: string; sliceId: string; epicId: string }[]; +}; + +/** + * Call an action handler, optionally route on a report payload field. + * Covers: evaluate, write-tests, write-code. + */ +type ActionDescriptor = { + kind: 'action'; + actionKey: string; + sliceId: string; + epicId: string; + /** If set, read report.payload[routeField] to decide routing. */ + routeField?: string; + /** Places to emit to when routeField is truthy (or always, if no routeField). */ + onTrue: string[]; + /** Places to emit to when routeField is falsy. */ + onFalse: string[]; + /** Place to return a fresh agent-resource token to. */ + agentReturnPlace?: string; +}; + +/** Test runner with retry budget — 3-way routing. */ +type RunTestsDescriptor = { + kind: 'run-tests'; + sliceId: string; + epicId: string; + target: string; + onPass: string[]; + onFail: string[]; + budgetPlace: string; +}; + +/** Semantic assessment with rework budget. */ +type AssessSemanticDescriptor = { + kind: 'assess-semantic'; + actionKey: string; + sliceId: string; + epicId: string; + onSatisfied: string[]; + onRejected: string[]; + budgetPlace: string; + maxReworks: number; +}; + +/** Mark slice completed, emit dep-signal tokens. */ +type CompleteSliceDescriptor = { + kind: 'complete-slice'; + sliceId: string; + epicId: string; + completedPlace: string; + depSignals: string[]; +}; + +/** Mark epic completed, emit dep-signal tokens. */ +type CompleteEpicDescriptor = { + kind: 'complete-epic'; + epicId: string; + donePlace: string; + depSignals: string[]; +}; + +/** Verify epic — action call + pass/fail routing + halt on fail. */ +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 }[]; +}; + +export type HandlerDescriptor = + | PassthroughDescriptor + | ActionDescriptor + | RunTestsDescriptor + | AssessSemanticDescriptor + | CompleteSliceDescriptor + | CompleteEpicDescriptor + | VerifyEpicDescriptor; + +// --------------------------------------------------------------------------- +// Transition skeleton — topology + declarative handler recipe +// --------------------------------------------------------------------------- + +export type TransitionSkeleton = { + id: string; + inputs: string[]; + contract: TransitionContract; + handler: HandlerDescriptor; +}; + +// --------------------------------------------------------------------------- +// NetBlueprint — the full declarative net shape +// --------------------------------------------------------------------------- + +export type NetBlueprint = { + places: string[]; + transitions: TransitionSkeleton[]; + initialTokens: { place: string; token: TokenSeed }[]; +}; diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 7cc9f000..c3a6df33 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -1,24 +1,15 @@ // --------------------------------------------------------------------------- -// Net compiler — compiles a Plan into a PetriNet with wired transitions. -// Extracted from engine-petri.ts for Phase 0. +// Net compiler — two-pass pipeline: +// 1. compileTopology(plan, policy) → NetBlueprint (pure, no runtime refs) +// 2. wireHandlers(blueprint, input, ctx) → PetriNet (attaches fire closures) +// 3. compilePlan(input, ctx) → PetriNet (convenience wrapper) // --------------------------------------------------------------------------- +import type { NetBlueprint, TokenSeed, TransitionSkeleton } from './net-blueprint.js'; import { PetriNet } from './petri-net.js'; import type { Token } from './petri-net.js'; import { createReport } from './report-helpers.js'; -import type { ActionContext, EpicOutcome, OrchestratorInput, SliceOutcome } from './types.js'; - -// --------------------------------------------------------------------------- -// Mutable run context — shared between compiler and orchestrator -// --------------------------------------------------------------------------- - -export type RunCtx = { - reportIds: string[]; - sliceOutcomes: Map; - epicOutcomes: Map; - halted: boolean; - haltReason?: string; -}; +import type { ActionContext, OrchestratorInput, Plan, RunCtx, RunPolicy } from './types.js'; // --------------------------------------------------------------------------- // Place-id helpers @@ -33,42 +24,37 @@ function ep(epicId: string, place: string): string { } // --------------------------------------------------------------------------- -// compilePlan — builds the full PetriNet for a plan +// Pass 1 — compileTopology: pure function, no closures over runtime state. +// Same Plan + Policy → same blueprint. Trivially snapshot-testable. // --------------------------------------------------------------------------- -export function compilePlan(input: OrchestratorInput, ctx: RunCtx): PetriNet { - const net = new PetriNet(); - const { plan, actions, testRunner, reports, policy } = input; +export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { + const places: string[] = []; + const transitions: TransitionSkeleton[] = []; + const initialTokens: { place: string; token: TokenSeed }[] = []; // Epic-level places for (const epic of plan.epics) { - net.addPlace(ep(epic.id, 'done')); + places.push(ep(epic.id, 'done')); } - // Helper: fan out epic readiness to all its slices' eligible places - function epicReadyOutputs(epicId: string): { place: string; token: Token }[] { - return plan.slices - .filter((s) => s.epic_id === epicId) - .map((s) => ({ place: p(s.id, 'eligible'), token: { sliceId: s.id, epicId } })); - } - - // Seed epic readiness — epics with no deps start ready - // (deferred until eligible places exist — see below) - const seedEpics = plan.epics.filter((e) => e.depends_on.length === 0); - - // Epic dependency wiring — per-dependent signal places (avoids token starvation - // when multiple epics depend on the same predecessor) + // Epic dependency wiring for (const epic of plan.epics) { if (epic.depends_on.length > 0) { const signalPlaces = epic.depends_on.map((depId) => { const signalPlace = ep(depId, `dep-signal:${epic.id}`); - net.addPlace(signalPlace); + places.push(signalPlace); return signalPlace; }); - net.addTransition({ + // Fan out epic readiness to all its slices' eligible places + const sliceOutputs = plan.slices + .filter((s) => s.epic_id === epic.id) + .map((s) => ({ place: p(s.id, 'eligible'), sliceId: s.id, epicId: epic.id })); + transitions.push({ id: `epic-deps-met:${epic.id}`, inputs: signalPlaces, - fire: async () => epicReadyOutputs(epic.id), + contract: { kind: 'structural', lane: 'epic', guard: 'all epic dependencies done' }, + handler: { kind: 'passthrough', outputs: sliceOutputs }, }); } } @@ -77,9 +63,9 @@ export function compilePlan(input: OrchestratorInput, ctx: RunCtx): PetriNet { for (const slice of plan.slices) { const epic = plan.epics.find((e) => e.id === slice.epic_id)!; const sid = slice.id; - const baseToken: Token = { sliceId: sid, epicId: epic.id }; + const base: TokenSeed = { sliceId: sid, epicId: epic.id }; - // Places + // Places — mechanical lane for (const name of [ 'spec-ready', 'test-agent', @@ -90,232 +76,434 @@ export function compilePlan(input: OrchestratorInput, ctx: RunCtx): PetriNet { 'done-spec', 'completed', ]) { - net.addPlace(p(sid, name)); + places.push(p(sid, name)); } - // Initial tokens (agent resources) - net.addToken(p(sid, 'test-agent'), { ...baseToken }); - net.addToken(p(sid, 'code-agent'), { ...baseToken }); + // Places — semantic lane + places.push(p(sid, 'semantic-budget')); + places.push(p(sid, 'semantic-satisfied')); - // Slice readiness gate — collects per-slice prerequisite tokens - net.addPlace(p(sid, 'eligible')); + // Retry + semantic budget places + places.push(p(sid, 'retry-budget')); + // Eligibility gate + places.push(p(sid, 'eligible')); + + // Initial tokens + initialTokens.push( + { place: p(sid, 'test-agent'), token: { ...base } }, + { place: p(sid, 'code-agent'), token: { ...base } }, + { place: p(sid, 'semantic-budget'), token: { ...base, reworkCount: 0 } }, + { place: p(sid, 'retry-budget'), token: { ...base, retryCount: 0 } }, + ); + + // Slice readiness gate if (slice.depends_on.length === 0) { - // No slice deps — eligible when epic is ready (token seeded below) - net.addTransition({ + transitions.push({ id: `slice-ready:${sid}`, inputs: [p(sid, 'eligible')], - fire: async () => [{ place: p(sid, 'spec-ready'), token: { ...baseToken } }], + contract: { kind: 'structural', lane: 'mechanical', guard: 'slice eligible' }, + handler: { + kind: 'passthrough', + outputs: [{ place: p(sid, 'spec-ready'), sliceId: sid, epicId: epic.id }], + }, }); } else { - // Has slice deps — eligible needs its own token AND all dep completions - const gateInputs = [p(sid, 'eligible'), ...slice.depends_on.map((d) => p(d, 'dep-signal:' + sid))]; + const gateInputs = [p(sid, 'eligible'), ...slice.depends_on.map((d) => p(d, `dep-signal:${sid}`))]; for (const depId of slice.depends_on) { - net.addPlace(p(depId, 'dep-signal:' + sid)); + places.push(p(depId, `dep-signal:${sid}`)); } - net.addTransition({ + transitions.push({ id: `slice-ready:${sid}`, inputs: gateInputs, - fire: async () => [{ place: p(sid, 'spec-ready'), token: { ...baseToken } }], + contract: { kind: 'structural', lane: 'mechanical', guard: 'slice eligible + all deps done' }, + handler: { + kind: 'passthrough', + outputs: [{ place: p(sid, 'spec-ready'), sliceId: sid, epicId: epic.id }], + }, }); } - const actCtx: ActionContext = { - slice, - epic, - plan, - worktreeDir: input.worktreeDir, - reports, - }; - - // Evaluate — conditional: NO → needs-more, YES → done-spec - net.addTransition({ + // Evaluate + transitions.push({ id: `${sid}:evaluate`, inputs: [p(sid, 'spec-ready'), p(sid, 'test-agent')], - fire: async (consumed) => { - const reportId = await actions['evaluate-done'](actCtx); - ctx.reportIds.push(reportId); - const report = reports.getById(reportId); - const done = !!(report?.payload as { done?: boolean })?.done; - const tok: Token = { ...consumed[0]!, reportId }; - if (done) { - return [ - { place: p(sid, 'done-spec'), token: tok }, - { place: p(sid, 'test-agent'), token: { ...baseToken } }, - ]; - } - return [ - { place: p(sid, 'needs-more'), token: tok }, - { place: p(sid, 'test-agent'), token: { ...baseToken } }, - ]; + contract: { + kind: 'mechanical', + lane: 'mechanical', + actor: 'evaluator', + guard: 'spec-ready + test-agent available', + }, + handler: { + kind: 'action', + actionKey: 'evaluate-done', + sliceId: sid, + epicId: epic.id, + routeField: 'done', + onTrue: [p(sid, 'done-spec')], + onFalse: [p(sid, 'needs-more')], + agentReturnPlace: p(sid, 'test-agent'), }, }); // Write tests - net.addTransition({ + transitions.push({ id: `${sid}:write-tests`, inputs: [p(sid, 'needs-more'), p(sid, 'test-agent')], - fire: async (consumed) => { - const reportId = await actions['write-tests'](actCtx); - ctx.reportIds.push(reportId); - return [ - { place: p(sid, 'failing-tests'), token: { ...consumed[0]!, reportId } }, - { place: p(sid, 'test-agent'), token: { ...baseToken } }, - ]; + contract: { kind: 'mechanical', lane: 'mechanical', actor: 'test-agent' }, + handler: { + kind: 'action', + actionKey: 'write-tests', + sliceId: sid, + epicId: epic.id, + onTrue: [p(sid, 'failing-tests')], + onFalse: [], + agentReturnPlace: p(sid, 'test-agent'), }, }); // Write code - net.addTransition({ + transitions.push({ id: `${sid}:write-code`, inputs: [p(sid, 'failing-tests'), p(sid, 'code-agent')], - fire: async (consumed) => { - const reportId = await actions['write-code'](actCtx); - ctx.reportIds.push(reportId); - return [ - { place: p(sid, 'untested-code'), token: { ...consumed[0]!, reportId } }, - { place: p(sid, 'code-agent'), token: { ...baseToken } }, - ]; + contract: { kind: 'mechanical', lane: 'mechanical', actor: 'coding-agent' }, + handler: { + kind: 'action', + actionKey: 'write-code', + sliceId: sid, + epicId: epic.id, + onTrue: [p(sid, 'untested-code')], + onFalse: [], + agentReturnPlace: p(sid, 'code-agent'), }, }); - // Retry budget — modeled as a place with a token carrying the count. - // Moved from ctx.retries Map to keep all control state inside the net. - net.addPlace(p(sid, 'retry-budget')); - net.addToken(p(sid, 'retry-budget'), { ...baseToken, retryCount: 0 }); - - // Run tests — orchestrator-owned, deterministic - net.addTransition({ + // Run tests + transitions.push({ id: `${sid}:run-tests`, inputs: [p(sid, 'untested-code'), p(sid, 'retry-budget')], - fire: async (consumed) => { - const retryToken = consumed[1]!; - const retryCount = retryToken.retryCount ?? 0; + contract: { + kind: 'mechanical', + lane: 'mechanical', + actor: 'test-runner', + guard: 'untested-code + retry-budget available', + }, + handler: { + kind: 'run-tests', + sliceId: sid, + epicId: epic.id, + target: slice.verification[0]?.target ?? '', + onPass: [p(sid, 'spec-ready')], + onFail: [p(sid, 'failing-tests')], + budgetPlace: p(sid, 'retry-budget'), + }, + }); - const target = slice.verification[0]?.target ?? ''; - const result = await testRunner.run(target, input.worktreeDir); - const reportId = createReport(reports, { - epicId: epic.id, - sliceId: sid, - actor: 'test-runner', - event: 'tests-run', - payload: { passed: result.passed, output: result.output }, - }); - ctx.reportIds.push(reportId); - - const tok: Token = { ...consumed[0]!, reportId }; - if (result.passed) { - // Reset retry budget on success - return [ - { place: p(sid, 'spec-ready'), token: tok }, - { place: p(sid, 'retry-budget'), token: { ...baseToken, retryCount: 0 } }, - ]; - } - if (retryCount >= policy.maxRetries) { - ctx.sliceOutcomes.set(sid, { sliceId: sid, status: 'halted' }); - ctx.halted = true; - ctx.haltReason = `Slice ${sid} retry exhaustion`; - return []; // dead end — no output tokens, retry budget consumed - } - return [ - { place: p(sid, 'failing-tests'), token: tok }, - { place: p(sid, 'retry-budget'), token: { ...baseToken, retryCount: retryCount + 1 } }, - ]; + // Assess semantic + const maxSemantic = policy.maxSemanticReworks ?? policy.maxRetries; + transitions.push({ + id: `${sid}:assess-semantic`, + inputs: [p(sid, 'done-spec'), p(sid, 'semantic-budget')], + contract: { + kind: 'semantic', + lane: 'semantic', + actor: 'semantic-assessor', + guard: 'done-spec + semantic-budget available', + }, + handler: { + kind: 'assess-semantic', + actionKey: 'assess-semantic', + sliceId: sid, + epicId: epic.id, + onSatisfied: [p(sid, 'semantic-satisfied')], + onRejected: [p(sid, 'needs-more')], + budgetPlace: p(sid, 'semantic-budget'), + maxReworks: maxSemantic, }, }); - // Return DONE — also emit dep-signal tokens for downstream slices + // Return DONE const dependents = plan.slices.filter((s) => s.depends_on.includes(sid)); - net.addTransition({ + transitions.push({ id: `${sid}:return-done`, - inputs: [p(sid, 'done-spec')], - fire: async () => { - ctx.sliceOutcomes.set(sid, { sliceId: sid, status: 'completed' }); - const outputs: { place: string; token: Token }[] = [ - { place: p(sid, 'completed'), token: { ...baseToken } }, - ]; - for (const dep of dependents) { - outputs.push({ place: p(sid, 'dep-signal:' + dep.id), token: { ...baseToken } }); - } - return outputs; + inputs: [p(sid, 'semantic-satisfied')], + contract: { kind: 'structural', lane: 'semantic', guard: 'semantic-satisfied' }, + handler: { + kind: 'complete-slice', + sliceId: sid, + epicId: epic.id, + completedPlace: p(sid, 'completed'), + depSignals: dependents.map((d) => p(sid, `dep-signal:${d.id}`)), }, }); } // Seed eligible places for epics with no dependencies + const seedEpics = plan.epics.filter((e) => e.depends_on.length === 0); for (const epic of seedEpics) { - for (const output of epicReadyOutputs(epic.id)) { - net.addToken(output.place, output.token); + for (const slice of plan.slices.filter((s) => s.epic_id === epic.id)) { + initialTokens.push({ place: p(slice.id, 'eligible'), token: { sliceId: slice.id, epicId: epic.id } }); } } - // Epic completion — all slices done → epic verification → epic done + // Epic completion for (const epic of plan.epics) { const epicSlices = plan.slices.filter((s) => s.epic_id === epic.id); - const completedPlaces = epicSlices.map((s) => p(s.id, 'completed')); - if (epicSlices.length === 0) continue; - // Find epics that depend on this one — emit dep-signal tokens on completion + const completedPlaces = epicSlices.map((s) => p(s.id, 'completed')); const epicDependents = plan.epics.filter((e) => e.depends_on.includes(epic.id)); - function epicDoneOutputs(): { place: string; token: Token }[] { - const outputs: { place: string; token: Token }[] = [ - { place: ep(epic.id, 'done'), token: { sliceId: '', epicId: epic.id } }, - ]; - for (const dep of epicDependents) { - outputs.push({ place: ep(epic.id, `dep-signal:${dep.id}`), token: { sliceId: '', epicId: epic.id } }); - } - return outputs; - } + const depSignals = epicDependents.map((dep) => ep(epic.id, `dep-signal:${dep.id}`)); if (epic.verification.length === 0) { - // No verification — slices done → epic done - net.addTransition({ + transitions.push({ id: `epic-complete:${epic.id}`, inputs: completedPlaces, - fire: async () => { - ctx.epicOutcomes.set(epic.id, { epicId: epic.id, status: 'completed' }); - return epicDoneOutputs(); + contract: { kind: 'structural', lane: 'epic', guard: 'all slices completed' }, + handler: { + kind: 'complete-epic', + epicId: epic.id, + donePlace: ep(epic.id, 'done'), + depSignals, }, }); } else { - // With verification — slices done → verify → epic done const verifyPlace = ep(epic.id, 'verify-ready'); - net.addPlace(verifyPlace); + places.push(verifyPlace); - net.addTransition({ + transitions.push({ id: `epic-slices-done:${epic.id}`, inputs: completedPlaces, - fire: async () => [{ place: verifyPlace, token: { sliceId: '', epicId: epic.id } }], + contract: { kind: 'structural', lane: 'epic', guard: 'all slices completed' }, + handler: { kind: 'passthrough', outputs: [{ place: verifyPlace, sliceId: '', epicId: epic.id }] }, }); - net.addTransition({ + const onPassOutputs = [ + { place: ep(epic.id, 'done'), sliceId: '', epicId: epic.id }, + ...depSignals.map((sig) => ({ place: sig, sliceId: '', epicId: epic.id })), + ]; + transitions.push({ id: `epic-verify:${epic.id}`, inputs: [verifyPlace], - fire: async () => { - const verifyCtx: ActionContext = { - slice: epicSlices[0]!, - epic, - plan, - worktreeDir: input.worktreeDir, - reports, - }; - const reportId = await actions['verify-epic'](verifyCtx); + contract: { kind: 'mechanical', lane: 'epic', actor: 'orchestrator', guard: 'verify-ready' }, + handler: { + kind: 'verify-epic', + actionKey: 'verify-epic', + epicId: epic.id, + representativeSliceId: epicSlices[0]!.id, + onPassOutputs, + }, + }); + } + } + + return { places, transitions, initialTokens }; +} + +// --------------------------------------------------------------------------- +// Pass 2 — wireHandlers: reads a blueprint, attaches fire closures. +// --------------------------------------------------------------------------- + +export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, ctx: RunCtx): PetriNet { + const net = new PetriNet(); + const { plan, actions, testRunner, reports, policy } = input; + + // Register places + for (const place of blueprint.places) { + net.addPlace(place); + } + + // Register transitions with wired fire handlers + for (const skel of blueprint.transitions) { + const h = skel.handler; + let fire: (consumed: Token[]) => Promise<{ place: string; token: Token }[]>; + + switch (h.kind) { + case 'passthrough': { + const outputs = h.outputs; + fire = async () => + outputs.map((o) => ({ place: o.place, token: { sliceId: o.sliceId, epicId: o.epicId } })); + break; + } + + case 'action': { + const { actionKey, sliceId, epicId, routeField, onTrue, onFalse, agentReturnPlace } = h; + const slice = plan.slices.find((s) => s.id === sliceId)!; + const epic = plan.epics.find((e) => e.id === epicId)!; + const actCtx: ActionContext = { slice, epic, plan, worktreeDir: input.worktreeDir, reports }; + const baseToken: Token = { sliceId, epicId }; + + fire = async (consumed) => { + const reportId = await actions[actionKey]!(actCtx); + ctx.reportIds.push(reportId); + const tok: Token = { ...consumed[0]!, reportId }; + + let route: string[]; + if (routeField) { + const report = reports.getById(reportId); + const val = !!(report?.payload as Record)?.[routeField]; + route = val ? onTrue : onFalse; + } else { + route = onTrue; + } + + const outputs: { place: string; token: Token }[] = route.map((pl) => ({ place: pl, token: tok })); + if (agentReturnPlace) { + outputs.push({ place: agentReturnPlace, token: { ...baseToken } }); + } + return outputs; + }; + break; + } + + case 'run-tests': { + const { sliceId, epicId, target, onPass, onFail, budgetPlace } = h; + const baseToken: Token = { sliceId, epicId }; + + fire = async (consumed) => { + const retryToken = consumed[1]!; + const retryCount = retryToken.retryCount ?? 0; + + const result = await testRunner.run(target, input.worktreeDir); + 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 (result.passed) { + return [ + ...onPass.map((pl) => ({ place: pl, token: tok })), + { place: budgetPlace, token: { ...baseToken, retryCount: 0 } }, + ]; + } + 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 } }, + ]; + }; + break; + } + + case 'assess-semantic': { + const { actionKey, sliceId, epicId, onSatisfied, onRejected, budgetPlace, maxReworks } = h; + const slice = plan.slices.find((s) => s.id === sliceId)!; + const epic = plan.epics.find((e) => e.id === epicId)!; + const actCtx: ActionContext = { slice, epic, plan, worktreeDir: input.worktreeDir, reports }; + const baseToken: Token = { sliceId, epicId }; + + fire = async (consumed) => { + const budgetToken = consumed[1]!; + const reworkCount = budgetToken.reworkCount ?? 0; + + const reportId = await actions[actionKey]!(actCtx); + ctx.reportIds.push(reportId); + const report = reports.getById(reportId); + const satisfied = !!(report?.payload as { satisfied?: boolean })?.satisfied; + + if (satisfied) { + 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 } }, + ]; + }; + break; + } + + case 'complete-slice': { + const { sliceId, epicId, completedPlace, depSignals } = h; + const baseToken: Token = { sliceId, epicId }; + + fire = async () => { + ctx.sliceOutcomes.set(sliceId, { sliceId, status: 'completed' }); + return [ + { place: completedPlace, token: { ...baseToken } }, + ...depSignals.map((sig) => ({ place: sig, token: { ...baseToken } })), + ]; + }; + break; + } + + case 'complete-epic': { + const { epicId, donePlace, depSignals } = h; + const baseToken: Token = { sliceId: '', epicId }; + + fire = async () => { + ctx.epicOutcomes.set(epicId, { epicId, status: 'completed' }); + return [ + { place: donePlace, token: { ...baseToken } }, + ...depSignals.map((sig) => ({ place: sig, token: { ...baseToken } })), + ]; + }; + break; + } + + case 'verify-epic': { + const { actionKey, epicId, representativeSliceId, onPassOutputs } = h; + const epic = plan.epics.find((e) => e.id === epicId)!; + const slice = plan.slices.find((s) => s.id === representativeSliceId)!; + const actCtx: ActionContext = { slice, epic, plan, worktreeDir: input.worktreeDir, reports }; + + fire = async () => { + 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(epic.id, { epicId: epic.id, status: 'completed' }); - return epicDoneOutputs(); + ctx.epicOutcomes.set(epicId, { epicId, status: 'completed' }); + return onPassOutputs.map((o) => ({ + place: o.place, + token: { sliceId: o.sliceId, epicId: o.epicId }, + })); } - ctx.epicOutcomes.set(epic.id, { epicId: epic.id, status: 'halted' }); + ctx.epicOutcomes.set(epicId, { epicId, status: 'halted' }); ctx.halted = true; - ctx.haltReason = `Epic ${epic.id} verification failed`; - return []; // dead end - }, - }); + ctx.haltReason = `Epic ${epicId} verification failed`; + return []; + }; + break; + } } + + net.addTransition({ + id: skel.id, + inputs: skel.inputs, + contract: skel.contract, + fire, + }); + } + + // Seed initial tokens + for (const { place, token } of blueprint.initialTokens) { + net.addToken(place, token as Token); } return net; } + +// --------------------------------------------------------------------------- +// compilePlan — convenience wrapper: compileTopology + wireHandlers. +// --------------------------------------------------------------------------- + +export function compilePlan(input: OrchestratorInput, ctx: RunCtx): PetriNet { + const blueprint = compileTopology(input.plan, input.policy); + return wireHandlers(blueprint, input, ctx); +} diff --git a/src/orchestrator/src/petri-net.ts b/src/orchestrator/src/petri-net.ts index 91af574c..754a2872 100644 --- a/src/orchestrator/src/petri-net.ts +++ b/src/orchestrator/src/petri-net.ts @@ -1,6 +1,5 @@ // --------------------------------------------------------------------------- -// Petri-net interpreter — extracted from engine-petri.ts for Phase 0. -// PetriNet class, Token, TransitionDef, and FiringPolicy live here. +// Petri-net interpreter — PetriNet class, Token, TransitionDef, FiringPolicy. // --------------------------------------------------------------------------- export type Token = { @@ -10,11 +9,32 @@ export type Token = { /** Retry counter — carried on retry-budget tokens. Phase 0 extension * to move retry state into the net instead of leaking to ctx.retries. */ retryCount?: number; + /** Semantic rework counter — carried on semantic-budget tokens. + * Prevents infinite rework loops when assess-semantic always rejects. */ + reworkCount?: number; +}; + +/** + * Typed metadata per transition — describes what a transition represents + * without affecting firing semantics. Enables the interpreter and event + * model to distinguish mechanical from semantic transitions. + */ +export type TransitionContract = { + /** Transition classification. */ + kind: 'mechanical' | 'semantic' | 'structural'; + /** Which subnet lane this transition belongs to. */ + lane?: 'mechanical' | 'semantic' | 'epic'; + /** What entity fires this transition. */ + actor?: 'coding-agent' | 'test-agent' | 'test-runner' | 'evaluator' | 'semantic-assessor' | 'orchestrator'; + /** Human-readable guard description (predicate logic is in the fire handler). */ + guard?: string; }; export type TransitionDef = { id: string; inputs: string[]; + /** Optional typed metadata — does not affect firing semantics. */ + contract?: TransitionContract; fire: (consumed: Token[]) => Promise<{ place: string; token: Token }[]>; }; @@ -25,6 +45,46 @@ export type TransitionDef = { */ export type FiringPolicy = 'serial'; +// --------------------------------------------------------------------------- +// §7 Event vocabulary — structured events emitted by the interpreter. +// --------------------------------------------------------------------------- + +/** Event kinds aligned with spec doc §7. */ +export type NetEventKind = 'transition_fired' | 'net_deadlocked' | 'net_halted'; + +/** Structured event emitted during net execution. */ +export type NetEvent = { + kind: NetEventKind; + ts: string; + transitionId?: string; + contract?: TransitionContract; + consumed?: string[]; + produced?: string[]; +}; + +/** Sink for structured net events. Optional — defaults to no-op. */ +export interface NetEventSink { + emit(event: NetEvent): void; +} + +/** Place names that may retain tokens after clean termination (resource pools, budgets, markers). */ +const BENIGN_RESIDUAL_PLACES = new Set([ + 'test-agent', + 'code-agent', + 'retry-budget', + 'semantic-budget', + 'completed', + 'done', +]); + +function placeName(placeId: string): string { + const sliceMatch = placeId.match(/^slice:[^:]+:(.+)$/); + if (sliceMatch) return sliceMatch[1]!; + const epicMatch = placeId.match(/^epic:[^:]+:(.+)$/); + if (epicMatch) return epicMatch[1]!; + return placeId; +} + export class PetriNet { private places = new Map(); private transitions: TransitionDef[] = []; @@ -58,10 +118,29 @@ export class PetriNet { return this.transitions.length; } - async run(_policy: FiringPolicy, shouldHalt?: () => boolean): Promise { + /** Returns registered transitions for inspection (e.g. adapter tests). */ + getTransitions(): ReadonlyArray { + return this.transitions; + } + + /** True when any non-resource place still holds tokens (actual deadlock, not clean completion). */ + private hasWorkBearingTokens(): boolean { + for (const [placeId, tokens] of this.places) { + if (tokens.length === 0) continue; + const name = placeName(placeId); + if (BENIGN_RESIDUAL_PLACES.has(name)) continue; + return true; + } + return false; + } + + async run(_policy: FiringPolicy, shouldHalt?: () => boolean, eventSink?: NetEventSink): Promise { // Phase 0: only serial policy — find first enabled, fire, repeat. while (true) { - if (shouldHalt?.()) break; + if (shouldHalt?.()) { + eventSink?.emit({ kind: 'net_halted', ts: new Date().toISOString() }); + break; + } const enabled = this.transitions.find((t) => t.inputs.every((p) => { @@ -69,7 +148,12 @@ export class PetriNet { return tokens && tokens.length > 0; }), ); - if (!enabled) break; + if (!enabled) { + if (this.hasWorkBearingTokens()) { + eventSink?.emit({ kind: 'net_deadlocked', ts: new Date().toISOString() }); + } + break; + } // Consume one token per input place const consumed: Token[] = []; @@ -79,9 +163,21 @@ export class PetriNet { // Fire — handler decides outputs const outputs = await enabled.fire(consumed); + const producedPlaces: string[] = []; for (const { place, token } of outputs) { this.addToken(place, token); + producedPlaces.push(place); } + + // Emit transition_fired event + eventSink?.emit({ + kind: 'transition_fired', + ts: new Date().toISOString(), + transitionId: enabled.id, + contract: enabled.contract, + consumed: enabled.inputs, + produced: producedPlaces, + }); } } } diff --git a/src/orchestrator/src/pi-actions.ts b/src/orchestrator/src/pi-actions.ts index 0fb77d86..92ee16c2 100644 --- a/src/orchestrator/src/pi-actions.ts +++ b/src/orchestrator/src/pi-actions.ts @@ -182,6 +182,12 @@ export function createPiActions(opts?: { verbose?: boolean; runStart?: number }) }); }, + 'assess-semantic': async (ctx: ActionContext) => { + log('?', `semantic ${ctx.slice.id}`); + // POC: auto-satisfy — real semantic assessment requires graph-derived gates (Phase 3) + return report(ctx, 'semantic-assessor', 'semantic-assessed', { satisfied: true }); + }, + 'verify-epic': async (ctx: ActionContext) => { log('▸', `verify ${ctx.epic.id}`); const targets = ctx.epic.verification.map((v) => `${v.kind}: ${v.target}`).join(', '); diff --git a/src/orchestrator/src/types.ts b/src/orchestrator/src/types.ts index 26523927..8ea18f0a 100644 --- a/src/orchestrator/src/types.ts +++ b/src/orchestrator/src/types.ts @@ -83,6 +83,8 @@ export interface TestRunner { export type RunPolicy = { maxRetries: number; + /** Maximum semantic rework cycles per slice before halting. Defaults to maxRetries. */ + maxSemanticReworks?: number; }; export type OrchestratorInput = { @@ -115,3 +117,15 @@ export type OrchestratorResult = { export interface Orchestrator { run(input: OrchestratorInput): Promise; } + +// --------------------------------------------------------------------------- +// Mutable run context — orchestrator-execution bookkeeping +// --------------------------------------------------------------------------- + +export type RunCtx = { + reportIds: string[]; + sliceOutcomes: Map; + epicOutcomes: Map; + halted: boolean; + haltReason?: string; +};