Skip to content

Add deterministic peer-loop reference MVP#119

Open
taras wants to merge 15 commits intomainfrom
deterministic-peer-loop-impl
Open

Add deterministic peer-loop reference MVP#119
taras wants to merge 15 commits intomainfrom
deterministic-peer-loop-impl

Conversation

@taras
Copy link
Copy Markdown
Owner

@taras taras commented Apr 19, 2026

Summary

Phase 2 of the Tisyn Deterministic Peer Loop: a single reference-MVP implementation landed as a private example package (@tisyn/example-deterministic-peer-loop). Forks examples/multi-agent-chat rather than modifying it.

  • Implements the §7.2 cycle verbatim: Taras gate (unbounded in required mode / timebox in optional mode), in-cycle App.readControl(), stop-before-pause, speaker override consumed + cleared via DB.writeControl same cycle, default alternation, at-most-one peer step per cycle, independent TurnEntry + PeerRecord persistence, App.showMessage for every persisted message, and requestedEffects disposition via the policy module.
  • Wires Opus (Claude) and GPT (Codex) peers through @tisyn/claude-code and @tisyn/codex in capability-restricted posture, with a fresh CodeAgent session per turn. Peer wrappers parse strict-JSON PeerTurnResult through the TypeBox PeerTurnResultSchema — no raw prose leaks into control flow.
  • Operator control panel (paused / stopRequested / nextSpeakerOverride) with owner-vs-observer WebSocket hydration; observers get controlSnapshot on reconnect, updateControl is owner-only.
  • Scripted Core conformance harness (test/conformance/) covering 10 of 14 DPL-* categories with 22 tests. Harness captures OperationCall at the agent-dispatch boundary — never asserts on internal RecursiveState.
  • Single TypeBox schema module (src/schemas.ts) is the source of truth for persisted records, the store document, browser protocol messages, and PeerTurnResult. TS types are single-sourced via Static<typeof Schema>.
  • Private package per .changeset/config.json (privatePackages.version = false), so no changeset.

Scope

  • DPL categories covered: INIT, ALT, STEP, OVR, CTRL, RES, MODE, DONE, PER, EXEC.
  • Follow-up work (tracked separately, not in this PR): browser acceptance test adaptation (stale tisyn.config.ts input/include keys inherited from the fork), live-adapter smoke tests (env-gated), and additional Core coverage for GATE / RPL / TYPE / CAP categories toward the 64-test target.

Test plan

  • pnpm -C examples/deterministic-peer-loop build:workflow — generates workflow IR
  • pnpm -C examples/deterministic-peer-loop build:node — node bundle compiles
  • pnpm -C examples/deterministic-peer-loop build:browser — browser bundle builds (417 kB / 123 kB gzip)
  • pnpm -C examples/deterministic-peer-loop test — 26/26 tests pass (22 conformance + 4 unit)
  • Repo-wide pnpm -w test — no regression (2093+ tests across 22 packages/examples pass; examples/multi-agent-chat unchanged)
  • pnpm -C examples/deterministic-peer-loop test:browser — pending in follow-up (config needs roots migration)
  • Live-adapter smoke — pending in follow-up (env-gated)

taras added 11 commits April 19, 2026 20:33
Forks examples/multi-agent-chat into examples/deterministic-peer-loop and
implements the §7.2 cycle from the deterministic peer-loop specification:
Taras gate (timebox in optional mode), control read, stop-before-pause,
speaker override with same-cycle clear, default alternation, single peer
step per cycle, independent TurnEntry + PeerRecord persistence, and
requested-effect disposition with the policy module.

Wires Opus (Claude) and GPT (Codex) peers through @tisyn/claude-code and
@tisyn/codex in capability-restricted posture, adds the operator control
panel (paused / stopRequested / nextSpeakerOverride) with owner/observer
WebSocket hydration, and lands the scripted Core conformance harness
covering 10 of 14 DPL-* categories (22 tests).
BrowserSessionManager previously tracked a single socket, so non-owner
browsers received a one-time hydrate and no further pushes. Split the
socket state into an owner reference plus an observer set, and fan out
loadChat, showMessage, setReadOnly, and control updates to every
connected client. Elicit prompts remain owner-only.
The host emits showMessage frames as { type, speaker, content }, matching
HostShowMessageSchema. The React client was reading msg.entry, so every
peer turn produced an undefined transcript row. Read speaker and content
directly from the frame.
The UI sends nextSpeakerOverride: null when the operator picks "auto",
but the patch schema derived from Type.Partial(LoopControl) only accepted
undefined. Define BrowserControlPatchSchema explicitly so null is valid,
and normalize it to "field absent" when merging into the stored control
record. LoopControlSchema itself stays strict.
Remove the promise-backed EffectsProcessor agent and route each
requested effect through the planned per-effect surface: Policy.decide
selects a disposition and, for executed entries, EffectHandler.invoke
runs the handler. Per-effect EffectRequestRecord rows now land as
distinct appendEffectRequest calls.

The authored workflow iterates via a stateful EffectsQueue helper
(seed / shift) so the drain runs in a single non-nested while loop,
and the dispatch steps live in exported sub-workflows because the
compiler rejects nested while and try inside a loop body. EffectHandler
returns an InvokeOutcome union instead of throwing so the workflow
captures handler errors without a try/catch.

Conformance tests switch from an effectsScript batch to policyScript
plus dispatchScript entries, exercising all four dispositions end to
end through the new agents.
hydrateObserver only sent setReadOnly when a terminal reason was
already stored, so observers attaching mid-loop saw "connected" status
and a writable-looking control panel even though their messages are
dropped. Send the "Session owned by another browser" banner on every
observer attach, and keep the existing fallback that prefers a stored
terminal reason (done / stopped) when the workflow has already ended.
The harness imported Val from both @tisyn/ir and src/schemas.js, which
tsc rejects as a duplicate identifier on a clean build. Drop the
@tisyn/ir import — every use in this file is the local schemas.js Val
type.
Observers entered read-only on attach but became writable again as soon
as the server broadcast showMessage or loadChat, because useChat stored
the read-only reason in the same `status` slot that live activity
updates overwrote. Give read-only its own state cell (`readOnlyReason`)
so once set it persists for the session, and derive the control panel
`disabled` prop and banner text from it instead of `status.level`.
Lint was failing on the PR branch:
- unicorn/no-useless-fallback-in-spread on `...(initial ?? {})`.
- Four curly violations on single-statement if/for bodies.

Also apply oxfmt to the example so `pnpm run format:check` is clean.
Behavior unchanged; this is purely lint and formatter conformance.
PR #120 removed the `{ input: ... }` wrapper for single-parameter
ambient agent calls. The conformance harness was still destructuring
`{ input }` from every payload and re-wrapping args in the operations
log, which broke every captured call shape under the new contract.
Destructure the payload directly and record it verbatim so the harness
matches what the runtime now dispatches.
@taras taras force-pushed the deterministic-peer-loop-impl branch from 6afe602 to b8299fc Compare April 20, 2026 00:38
taras added 4 commits April 19, 2026 20:40
The ambient declaration `takeTurn(input: { input: PeerTurnInput })`
was an artifact of the pre-PR-#120 wrapper style. Under the direct
payload contract it lowered to `operation<{ input: PeerTurnInput },
PeerTurnResult>` — an awkward single-key wrapper with no remaining
purpose. Drop the wrapper so the payload is `PeerTurnInput` directly.
Updates the declaration, the OperationSpec in peers/agents.ts, the
workflow call sites, the Opus/GPT bindings, and the harness
OperationCall type in lock-step.
The workflow constructed every peer TurnEntry with `usage: result.usage`
unconditionally. When a peer returned no usage, this produced a
present-but-undefined `usage` key that `TurnEntrySchema` rejected
(`Expected object`), crashing store validation on the first turn.

Select between two TurnEntry shapes with a truthy-check ternary so the
key is absent when usage is missing and included verbatim when present.
TurnEntrySchema is unchanged. Adds PER-07 covering the omission path
(including the published `showMessage` frame) alongside the existing
PER-06 pass-through test.
Replaces the file-backed DB agent and parallel JSON store with a
stateless Projection reducer agent. Application-level state
(transcript, LoopControl, peer records, effect-request records,
readOnlyReason) lives in workflow locals and is rebuilt on restart
via runtime replay of journaled Projection-op return values. The
App surface collapses per-message fan-out (showMessage, loadChat,
readControl, setReadOnly) into a single per-iteration hydrate
snapshot and a blocking-pull nextControlPatch for browser-origin
control updates.

The workflow drains queued control patches inline via
timebox(0, App.nextControlPatch) immediately after the Taras gate
so every stop/pause/override check observes all patches the
browser has sent during the iteration. A new peerLoop return
value (FinalSnapshot) and a host-side post-execute publish step
in main.ts guarantee browser hydration on terminal replay-only
restarts when execute() returns without any live dispatch.

Extracts runDescriptorLocally from the tsn run CLI path so the
example's main.ts can keep the WebSocket server alive past the
workflow's return.

Rebaselines the conformance harness and 27 tests against the new
OperationCall surface, adds a 9-test projection-reducer unit suite,
and adds DPL-JNL-01/02 replay tests asserting journal-driven
reconstruction and post-frontier hydrate dispatch. Updates the
specification (§4.1 App, §4.2 Projection, §6.10 LOOP-PERSIST-1,
§7.1/§7.2 initial state and cycle body, §11 rejected alternatives)
and the test plan (DPL-CTRL/OVR/DONE/PER/INIT/RPL rewrites, new
DPL-JNL category, fixture and OperationCall schema updates).
CI fails with ERR_PNPM_OUTDATED_LOCKFILE because the previous
commit moved @tisyn/cli from devDependencies to dependencies in
the example's package.json (main.ts now imports
runDescriptorLocally at runtime) without regenerating the
lockfile.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant