Skip to content

fix(studio): memoize only a launch that was actually attempted - #515

Merged
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-185-fix-studio-auto-launch-memo-reme
Aug 28, 2026
Merged

fix(studio): memoize only a launch that was actually attempted#515
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-185-fix-studio-auto-launch-memo-reme

Conversation

@KnockOutEZ

Copy link
Copy Markdown
Owner

Closes wigolo-studio-run#185 (PX0 exit-4, K1/K3/K5 + coverage gap 2).

What was wrong

ensureStudioRunning's memo was written on the shared promise from a bare null, which cannot
distinguish a launch that ran and produced no handle from one that never started. A launcher
DECLINE — the normal outcome when substratePresent()'s 5s TTL disagrees with defaultLaunch's
uncached record read — was therefore remembered for 60s, so a substrate reinstalled a second later
was declined with zero spawn attempts: the lockout the memo's own docstring forbids.

Three siblings landed with it: memoMs: 0 disabled the write but not the read, so the documented
escape hatch was silently declined by another caller's window; normalizeLaunch read any object as
an outcome, so an async launcher's Promise (.started === undefined) became a silent instant
decline with the poll skipped; and DEFAULT_NO_HANDLE_MEMO_MS was unpinned — raising it to an hour
left all 103 arms green because every memo arm passed memoMs explicitly.

What changed

  • The launch pass reports { handle, attempted }. Only attempted && !handle is memoized, which
    makes the comment above the gate true. A launcher that threw counts as not attempted for the same
    reason a decline does — nothing started, nothing waited for, re-attempt costs one throw.
  • memoMs: 0 is honoured at the read as well as the write. It does not disable single-flight.
  • normalizeLaunch throws on a thenable, and on an object with no boolean started.
  • The shipped 60s window is pinned behaviourally (a caller walked across the boundary), not by an
    equality assert.

Decisions recorded in DECISIONS-AUTO as A-185-1 (read-honouring for memoMs: 0) and A-185-2 (a
throw is not memoized), each with its reversal condition.

Demo — red before / green after

Arm (a), against the tip before the fix:

 FAIL  |unit| tests/unit/studio/auto-launch.test.ts > a launch that produced no handle is
   remembered briefly > does not memoize a LAUNCHER decline — a substrate reinstalled a tick
   later is retried
AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times
 ❯ tests/unit/studio/auto-launch.test.ts:595:22
     593|       const launch = vi.fn(() => { publishHandle(); });
     594|       const h = await ensureStudioRunning({ dataDir: dir, launch, laun…
     595|       expect(launch).toHaveBeenCalledTimes(1);

      Tests  5 failed | 29 passed (34)

After: Tests 34 passed (34).

Mutation probe for the coverage arm — DEFAULT_NO_HANDLE_MEMO_MS set to 3_600_000:

      Tests  1 failed | 33 passed (34)
     × pins the shipped default window — long enough to spare one fan-out, short enough not to lock out

Constant restored by reverse-edit; suite green again.

Verification

npm test              → Test Files 955 passed | 5 skipped (960)
                        Tests 12054 passed | 20 skipped | 7 todo (12081)   exit 0
npx tsc --noEmit      → exit 0
npm run gate:studio   → exit 0

CI deferred — quota.

A launcher decline, the memoMs:0 read/write asymmetry, a thenable read as
an outcome, and the shipped 60s window all land as arms before the fix.
normalizeLaunch is exported so the shape rejection is asserted at its own
seam rather than inferred from a null two layers away.
A decline arrived back through the shared promise as a bare null, so it was
remembered for 60s exactly like a launch that ran and timed out. The commonest
decline is the substrate presence TTL disagreeing with an uncached record read,
which a reinstall resolves in the next second — so a fixed machine was locked
out for the rest of the window. The pass now reports whether anything was
started, and only that case is remembered.

memoMs 0 is honoured at the read as well as the write, so the documented
escape hatch is not silently declined by another caller's window.
normalizeLaunch throws on a thenable or on an object with no boolean started,
instead of reading undefined as a decline and skipping the poll.
@KnockOutEZ
KnockOutEZ merged commit 89f423b into studio-handoff-core Aug 28, 2026
19 checks passed
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5a476c40-8a98-4dee-93ad-431e13608d5c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@KnockOutEZ
KnockOutEZ deleted the sd-185-fix-studio-auto-launch-memo-reme branch August 28, 2026 05:30
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