The visual-repair pass on #15099 exposed a misleading progress contract: the Demo-C header could advance from the runner's beat event before the corresponding operation, assertion, pause, runtime cue, and projection had settled. The local draft repair serializes progress behind TourRunner.log.length, the hosting surface's cue promise, and its projection promise. That removes the visible jump, but polling the runner's mutable log is an app-local adapter around a missing lifecycle signal.
A live latest-20-open sweep plus all-state GitHub search was checked at 2026-07-13T19:11Z; no equivalent ticket exists. The current A2A all-state window contains no overlapping claim or intent.
The Problem
Neo.ai.client.TourRunner exposes:
beat before every step, intentionally serving captions and data-only runtime cues;
error on abort;
complete after the full script.
It does not expose the successful boundary between those two ends. A progress consumer therefore has only three bad choices: claim progress from the pre-step beat, poll log.length, or wait until the entire tour completes.
This is observable in DemoCWorkspace.waitForTourBeatSettlement(): a bounded polling loop waits for the log entry before it can safely paint the next pip. The code works as containment, but it leaks runner internals into the hosting surface and can drift as new step types land.
Architectural Reality
src/ai/client/TourRunner.mjs#run fires beat before dispatch, then appends one deterministic log entry only after each runner-owned step has completed.
For op, runner-owned settlement means the dock executor resolved, the log entry exists, and any post-operation expectations passed.
For topology-assert, it means the topology read and expectations passed.
For pause, it means the mode-specific pacing wait completed.
Hosting-surface cues and view projection remain hosting-surface authority. Closed ticket #15009 deliberately kept deferred view settlement in the workspace; this ticket does not pull DOM or projection ownership into the runner.
The Fix
Add one additive observable event, stepSettled, emitted after every successful runner-owned step and before the next beat.
The deterministic, timestamp-free payload contains:
sceneId, sceneIndex, stepIndex, and stepType;
one-based completedCount;
logLength matching the runner's completed log.
Then migrate Demo C's progress adapter to consume stepSettled and combine it with its existing cue/projection promise. Delete the log-length polling loop; runtime-only cue settlement stays local.
Contract Ledger
Target surface
Source of authority
Proposed behavior
Fallback / failure
Docs
Evidence
pre-step narration
TourRunner.beat
remains pre-step and unchanged
no compatibility change
class event summary
event-order unit spec
runner-owned step completion
TourRunner#run
fire exactly one stepSettled after a successful step
rejected/failed step emits no settled event; existing error path wins
class + event payload JSDoc
op/assert/pause unit matrix
Demo-C progress
DemoCWorkspace cue + projection promises
advance one pip after stepSettled and local surface settlement
cue/projection failure remains visible in the tour receipt
method JSDoc
focused whitebox E2E exact sequence
Decision Record impact
none — additive lifecycle precision inside the existing neo.tour.script.v1 runner; no script-schema or authority change.
Acceptance Criteria
beat remains a pre-step caption/cue event.
stepSettled fires exactly once after each successful op, topology-assert, and pause step.
The payload is timestamp-free, JSON-safe, and carries sceneId, sceneIndex, stepIndex, stepType, completedCount, and logLength.
A rejected operation or failed expectation emits error without a false stepSettled for that step.
Focused unit coverage pins beat → runner work → stepSettled → next beat order in spec, plus pause settlement in paced mode.
Demo C consumes the event and removes waitForTourBeatSettlement()'s log polling while retaining local cue/projection settlement.
Demo-C whitebox E2E observes the exact monotonic progress sequence with no fill-then-reset frame.
Class and consumer JSDoc name the runner-owned versus hosting-surface settlement boundary.
Out of Scope
Awaiting arbitrary Observable listener promises · moving runtime cue execution into TourRunner · DOM/view-projection ownership · changing pause semantics · a new script schema version.
Avoided Traps
Do not rename or move beat; it is correctly pre-step.
Do not make fire() globally await listener return values.
Do not include timestamps or mutable log-array aliases in the payload.
Do not treat stepSettled as proof that a hosting surface's asynchronous cue or projection has painted.
Context
The visual-repair pass on #15099 exposed a misleading progress contract: the Demo-C header could advance from the runner's
beatevent before the corresponding operation, assertion, pause, runtime cue, and projection had settled. The local draft repair serializes progress behindTourRunner.log.length, the hosting surface's cue promise, and its projection promise. That removes the visible jump, but polling the runner's mutable log is an app-local adapter around a missing lifecycle signal.A live latest-20-open sweep plus all-state GitHub search was checked at 2026-07-13T19:11Z; no equivalent ticket exists. The current A2A all-state window contains no overlapping claim or intent.
The Problem
Neo.ai.client.TourRunnerexposes:beatbefore every step, intentionally serving captions and data-only runtime cues;erroron abort;completeafter the full script.It does not expose the successful boundary between those two ends. A progress consumer therefore has only three bad choices: claim progress from the pre-step
beat, polllog.length, or wait until the entire tour completes.This is observable in
DemoCWorkspace.waitForTourBeatSettlement(): a bounded polling loop waits for the log entry before it can safely paint the next pip. The code works as containment, but it leaks runner internals into the hosting surface and can drift as new step types land.Architectural Reality
src/ai/client/TourRunner.mjs#runfiresbeatbefore dispatch, then appends one deterministic log entry only after each runner-owned step has completed.op, runner-owned settlement means the dock executor resolved, the log entry exists, and any post-operation expectations passed.topology-assert, it means the topology read and expectations passed.pause, it means the mode-specific pacing wait completed.#15009deliberately kept deferred view settlement in the workspace; this ticket does not pull DOM or projection ownership into the runner.The Fix
Add one additive observable event,
stepSettled, emitted after every successful runner-owned step and before the nextbeat.The deterministic, timestamp-free payload contains:
sceneId,sceneIndex,stepIndex, andstepType;completedCount;logLengthmatching the runner's completed log.Then migrate Demo C's progress adapter to consume
stepSettledand combine it with its existing cue/projection promise. Delete the log-length polling loop; runtime-only cue settlement stays local.Contract Ledger
TourRunner.beatTourRunner#runstepSettledafter a successful steperrorpath winsDemoCWorkspacecue + projection promisesstepSettledand local surface settlementDecision Record impact
none — additive lifecycle precision inside the existing
neo.tour.script.v1runner; no script-schema or authority change.Acceptance Criteria
beatremains a pre-step caption/cue event.stepSettledfires exactly once after each successfulop,topology-assert, andpausestep.sceneId,sceneIndex,stepIndex,stepType,completedCount, andlogLength.errorwithout a falsestepSettledfor that step.beat → runner work → stepSettled → next beatorder inspec, plus pause settlement in paced mode.waitForTourBeatSettlement()'s log polling while retaining local cue/projection settlement.Out of Scope
Awaiting arbitrary Observable listener promises · moving runtime cue execution into TourRunner · DOM/view-projection ownership · changing pause semantics · a new script schema version.
Avoided Traps
beat; it is correctly pre-step.fire()globally await listener return values.stepSettledas proof that a hosting surface's asynchronous cue or projection has painted.Related
Related: #15099 · #15009 · #13158
Origin Session ID: f95e01ff-ba36-409a-98af-573263fab247
Retrieval Hint:
TourRunner stepSettled post-step progress beat pre-step log polling Demo C