refactor: extract ResourceScope and KeyedSerializer helpers#83
Conversation
Address renderer dispose and per-session manifest serialization with two
small, dependency-free helpers and refactor the agreed callsites.
- src/util/resourceScope.ts: LIFO release, attempts every release after
a failure, throws ResourceScopeCloseError carrying { name, error } per
failure, idempotent close(), assert-guarded add().
- src/util/keyedSerializer.ts: per-key sequential chains via single-arg
.then(() => operation()) for cascading rejection, .finally(...) chain
cleanup.
- src/storage/artifactManifest.ts: replace the module-local appendQueues
Map with a KeyedSerializer<string>; appendArtifact() now serializes
per resolved session directory.
- src/renderer/ghosttyWeb/backend.ts: replace cleanupHandles() with a
per-lifecycle ResourceScope. Each resource (server, browser,
browserContext, page) registers its release at acquisition time using
captured locals. boot-failure rollback and dispose() both close the
scope through closeResourceScopeAndLog(), which logs each
ResourceScopeCloseError.failures entry via this.logger.warn and
resolves successfully. Renderer state nulling/reset is preserved.
- src/host/eventLog.ts: clarifying comment that writeQueue is
intentionally poisoning (event log is canonical execution truth).
- src/host/hostMain.ts: clarifying comment that enqueuePtyIngestion
intentionally recovers after rejection.
- test/unit/util/{resourceScope,keyedSerializer}.test.ts: 15 new unit
tests for the helpers (TDD red-green-refactor).
- test/unit/storage/artifactStorage.test.ts: 20-way concurrent
appendArtifact() regression test.
- test/unit/renderer/ghosttyWebBackend.test.ts: dispose() resolves
successfully and warns once per registered release failure with
resource name + original error preserved.
- docs/adr/0001-use-release-it-only-for-release-prep.md renamed to
0002-... (resolves duplicate 0001 numbering).
- docs/adr/0003-renderer-dispose-swallows-cleanup-failures.md: new ADR
documenting the best-effort renderer dispose contract and the
per-lifecycle scope decision.
No new runtime dependency, no public CLI/protocol changes.
Validation: format-check, lint, typecheck, npm run verify
(1175/1175 tests, build, packaging smoke install) and a manual
renderer-forced smoke (create -> snapshot -> screenshot ->
destroy -> gc) against an isolated AGENT_TTY_HOME with a process
baseline showing no orphan Chromium/Playwright processes.
---
_Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
|
/coder-agents-review |
1 similar comment
|
/coder-agents-review |
There was a problem hiding this comment.
The helper extraction is well-done. ResourceScope and KeyedSerializer are small, dependency-free, and well-tested. The per-lifecycle scope deviation from the plan is the right call for the boot-after-dispose contract, and the ADR documents it clearly. The KeyedSerializer preserves cascading-failure semantics faithfully. The hostMain.ts and eventLog.ts clarifying comments are accurate.
The lifecycle management in disposeInternal has five findings at P2, all in the dispose/boot coordination area. Four are regressions from the old cleanupHandles behavior: (1) cleanup failure errors are silently eaten by JSON.stringify and produce "error":{} in operator logs, (2) field nulling is deferred to after scope close instead of before (old code nulled synchronously first), (3) disposePromise becomes a stale resolved Promise when scope is null (synchronous async body), and (4) scope.add() invariant during a boot+dispose race orphans the Chromium process. The fifth is that closeResourceScopeAndLog's logger.warn calls are unguarded, so an EPIPE during shutdown rejects dispose, violating the ADR.
Five P2, seven P3, five Nit.
Process notes: the plan specified two Graphite-stacked PRs with separate validation gates; this delivers everything in one commit. The commit message claims "TDD red-green-refactor" in a single atomic commit. The PR description states ResourceScopeCloseError extends Error to "avoid changing tsconfig," but the target is ES2024 and AggregateError has been available since ES2021.
"The cleanup failure message includes the resource names. Too bad it strips the actual errors before printing them." (Leorio)
src/renderer/ghosttyWeb/backend.ts:2204
P2 [DEREM-4] When this.resourceScope is null (dispose before boot, or after a boot failure where the catch block already closed the scope), disposeInternal() contains no await. The entire body, including the finally block that sets this.disposePromise = null, executes synchronously inside this.disposeInternal(). The outer assignment in dispose() then overwrites the null:
this.disposePromise = this.disposeInternal();
// ^-- runs synchronously, finally sets this.disposePromise = null
// ^-- assignment overwrites null with the returned resolved Promise
Sequence: (1) dispose() when scope is null; disposePromise = stale resolved Promise. (2) boot(). Awaits the stale promise (resolves immediately). Boots. Acquires resources. (3) dispose(). Checks this.disposePromise !== null (stale). Awaits it (resolves immediately). Returns. Resources not cleaned up.
The old code avoided this because cleanupHandles() was always called (even with null handles), and await cleanupHandles() always yields at least once (async function boundary), so the finally block ran after the outer assignment.
Fix: clear disposePromise in dispose() after the await, not inside disposeInternal()'s finally.
(Takumi P2)
🤖
🤖 This review was automatically generated with Coder Agents.
Address all 5 P2 + 7 P3 + 5 Nit findings from the first review:
ResourceScope (src/util/resourceScope.ts)
- DEREM-13: use Array.prototype.toReversed() (ES2024) instead of
index arithmetic.
- DEREM-14: rename `releases` field to `registrations` to match the
ResourceRegistration type and the loop variable.
- DEREM-17: drop the `if (registration === undefined)` guard; the
array is dense and toReversed() removes the noUncheckedIndexedAccess
fallout.
KeyedSerializer (src/util/keyedSerializer.ts)
- DEREM-10: document the same-key re-entrance hazard. run(key, ...)
must not be called from inside an operation queued under the same
key; the inner call would await the outer call's promise.
GhosttyWebBackend dispose / boot (src/renderer/ghosttyWeb/backend.ts)
- DEREM-2: pass failure.error directly to logger.warn so
formatLogDetail's `instanceof Error` branch keeps the original
Error instance instead of stringifying an Error wrapper into '{}'.
- DEREM-3: in disposeInternal() and bootInternal()'s catch, null
state and clear isBooted synchronously BEFORE awaiting scope.close().
Release closures captured local resource variables, so they are
unaffected. Concurrent operations checking requireOperationalPage()
now fail immediately on a clear invariant rather than acting on a
tearing-down resource.
- DEREM-4: clear disposePromise in dispose() after the await, not
inside disposeInternal()'s finally. When disposeInternal runs
synchronously (no scope to close), the inner finally would otherwise
execute before the outer assignment in dispose() lands and pin a
stale resolved Promise.
- DEREM-5: dispose() awaits this.bootPromise (with swallowed
rejection) before disposeInternal(), symmetric with boot()'s wait
on disposePromise. Prevents the race where dispose() reads
resourceScope between two awaits in bootInternal() and the
partially acquired browser handle is orphaned.
- DEREM-6: gate the page and browserContext release closures on a
new pageAndContextReleasedExternally flag. finalizeVideo() flips it
after closing those handles manually; the per-lifecycle scope's
release closures skip a redundant close on already-closed handles.
Reset on every bootInternal() and at the end of disposeInternal().
- DEREM-7: wrap each logger.warn call in safeWarn() that swallows
exceptions so an EPIPE during process shutdown cannot reject
dispose() and violate ADR 0003's best-effort contract.
- DEREM-8: memoize closeResourceScopeAndLog with a per-scope WeakSet
so a concurrent boot-failure rollback and dispose() racing on the
same scope reference cannot log the same failures twice.
Host queue comments
- DEREM-1: drop the trailing "see CONTEXT.md and the
ResourceScope/KeyedSerializer ADR" reference in src/host/eventLog.ts;
neither artifact exists. The poisoning explanation is self-contained.
- DEREM-15: rephrase the PTY ingestion queue comment in
src/host/hostMain.ts to drop the "Intentional...intentionally"
repetition.
Tests
- DEREM-11: add an empty-scope close() test.
- DEREM-12: drop the sort in the concurrent-writer artifact test;
KeyedSerializer guarantees submission-order persistence.
- DEREM-16: rewrite the dispose-cleanup-failure test to assert LIFO
order on the warn calls (browser-first, server-second) and verify
the original Error is passed as the second logger.warn argument.
PR description
- DEREM-9: replace the AggregateError motivation. ES2024 has it; the
reason for `extends Error` with a typed `failures` array is API
shape, not tsconfig.
Validation: format-check, lint, typecheck, npm run verify
(1175/1175 tests, build, packaging smoke install).
---
_Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
|
Addressing the remaining finding from the top-level review body in 42b387b: DEREM-4 — fixed. public async dispose(): Promise<void> {
if (this.disposePromise !== null) {
await this.disposePromise;
return;
}
this.disposePromise = this.disposeAfterBoot();
try {
await this.disposePromise;
} finally {
// DEREM-4: clear here, not inside disposeInternal()'s finally. When
// disposeInternal runs synchronously (no scope to close), its finally
// would otherwise execute before the outer assignment lands and leave
// a stale resolved Promise pinned on this.disposePromise.
this.disposePromise = null;
}
}The synchronous-disposeInternal scenario (scope is null, no awaits inside disposeInternal) no longer pins a stale resolved Promise on The "recovers state after dispose and re-boot" integration test still passes, and the new ordering interacts correctly with DEREM-5's Other findings from the body (DEREM-2/3/5/7) and the inline comments (DEREM-1, 6, 8–17) are addressed in the same commit and replied inline on each resolved thread. |
|
/coder-agents-review |
There was a problem hiding this comment.
All 17 R1 findings addressed cleanly in 42b387b. The fixes are well-traced (DEREM-N comments at each change site), and the verification panel confirmed correctness of the critical lifecycle changes: field-nulling-before-await (DEREM-3), disposePromise reset in dispose() (DEREM-4), disposeAfterBoot serialization (DEREM-5), safeWarn wrapper (DEREM-7), and loggedScopes dedup (DEREM-8).
Two new P3s, one P4. The P3s are a pre-existing race narrowed by this PR and a missing test for the safeWarn guard. Neither is blocking.
CI has one e2e failure (test-e2e 3/3). Worth checking whether it is related to the dispose changes or a flaky upstream.
"Good fights from R1, properly finished." (Hisoka)
🤖 This review was automatically generated with Coder Agents.
Round 2 review findings (1 P3 fix + 2 P3/P4 missing-test additions): - DEREM-18 (P3): recordUnexpectedFailure() now only clears bootPromise when boot has already fully succeeded (`wasBooted` snapshot). During an in-flight boot, leave bootPromise alone so a concurrent dispose() in disposeAfterBoot() observes a still-pending bootPromise and waits for bootInternal()'s catch to settle the rollback. Mitigates the pre-existing dispose-during-mid-boot-event-handler race that the PR narrowed but had not closed structurally. Full structural fix is tracked by #84. - DEREM-19 (P3): add unit test that mocks logger.warn to throw and asserts dispose() still resolves successfully, pinning the safeWarn() try/catch's role in honoring ADR 0003. - DEREM-20 (P4): add unit test that drives the private closeResourceScopeAndLog() helper twice on the same ResourceScope and asserts logger.warn was invoked exactly once, pinning the loggedScopes WeakSet dedup behavior. Validation: format-check, lint, typecheck, focused vitest run (974 tests across unit + renderer integration). --- _Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
|
/coder-agents-review |
There was a problem hiding this comment.
All 3 R2 findings addressed in 8297296. The recordUnexpectedFailure fix (DEREM-18) correctly gates bootPromise clearing behind wasBooted, closing the mid-boot race. The safeWarn test (DEREM-19) and loggedScopes dedup test (DEREM-20) pin their respective guards.
One new P3 from Bisky: the disposeAfterBoot boot-wait branch (DEREM-5 fix) has no unit test coverage because all three dispose tests leave bootPromise null. One Nit on a test comment.
Three rounds, 25 findings, 20 addressed, 3 dropped, 2 open. The remaining open items are a test coverage gap and a comment nit. The code is correct and the lifecycle contracts hold.
🤖 This review was automatically generated with Coder Agents.
- DEREM-24 (P3): add a unit test that pins the disposeAfterBoot() bootPromise wait branch (DEREM-5 fix). The test injects a pending bootPromise and a ResourceScope into the backend, calls dispose(), yields several microtasks to confirm the release closure has not run, then resolves the boot gate and asserts the recorded order is ['boot-settled', 'released']. Manual sanity check confirmed the test fails when the `if (this.bootPromise !== null)` branch in disposeAfterBoot() is bypassed. - DEREM-25 (Nit): rephrase the loggedScopes dedup test comment to describe what the test actually proves (sequential second-call short-circuit via memoized scope.close() rejection), and rename the `it()` accordingly. Validation: format-check, lint, typecheck, focused vitest run on the renderer backend test (15/15 green). --- _Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
|
/coder-agents-review |
There was a problem hiding this comment.
Both R3 findings addressed in fec84fc. The boot-wait test (DEREM-24) correctly parks dispose on a pending bootPromise and verifies ordering. The dedup test comment (DEREM-25) now accurately describes the mechanism.
One new P3: the sibling path where bootPromise rejects is untested. The try/catch in disposeAfterBoot is the only thing preventing a boot-failure rejection from breaking the ADR 0003 dispose contract.
Four rounds, 26 findings, 22 addressed, 3 dropped, 1 open. The open item is a test gap for the boot-rejection path. The production code is correct; the lifecycle contracts hold across all traced interleavings.
🤖 This review was automatically generated with Coder Agents.
Add a unit test that pins disposeAfterBoot()'s try/catch swallow of a rejecting bootPromise. The test is the rejection sibling of the DEREM-24 resolve test: it injects a pending bootPromise that rejects on `rejectBootGate(...)`, calls dispose(), yields several microtasks to confirm dispose is parked on bootPromise, then rejects the gate and asserts both that dispose() resolves and that the recorded order is ['boot-rejected', 'released']. Manual sanity check confirmed the test fails when the try/catch around `await this.bootPromise` in disposeAfterBoot() is removed, so the ADR 0003 dispose-resolves contract is now pinned across both boot success and boot failure. Validation: format-check, lint, typecheck, focused vitest run on the renderer backend test (16/16 green). --- _Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
|
/coder-agents-review |
There was a problem hiding this comment.
DEREM-26 addressed in 5058a29. The boot-rejection test pins the ADR 0003 contract across both boot success and boot failure paths. Three reviewers, zero new findings.
Five rounds complete. 26 findings raised, 23 fixed, 3 dropped. Zero open. The code is clean, the lifecycle contracts are verified, and the test suite covers every defensive guard.
🤖 This review was automatically generated with Coder Agents.
Summary
This change addresses concrete maintainability issues raised during the
Effect-library discussion without adopting Effect or rewriting the app. It
extracts two small, dependency-free helpers and refactors the two agreed
callsites:
ResourceScopefor renderer resource cleanup with deterministic LIFOrelease, aggregated
ResourceScopeCloseErrorfailure reporting, andidempotent
close().KeyedSerializer<K>for per-key serialized work, replacing the module-localappendQueuesMap insrc/storage/artifactManifest.ts.The renderer dispose path is now refactored onto a per-lifecycle
ResourceScopeso that each acquired resource (server,browser,browserContext,page) registers its release at acquisition time and theboot-failure rollback uses the same close + log helper as
dispose(). Rendererpublic
dispose()remains best-effort; failures are logged throughthis.logger.warnwith{ name, error }per release anddispose()resolvessuccessfully. The pre-existing
boot()afterdispose()contract (covered bytest/integration/renderer-backend.test.ts) is preserved.Notable choices and deviations from the original plan
ResourceScopeinstead of a single class-level scope,because the integration test "recovers state after dispose and re-boot"
exercises a second boot. The plan explicitly anticipated this fallback.
ResourceScopeCloseError extends Errorwith afailures: readonly ResourceScopeFailure[]field instead ofAggregateError. The projecttargets ES2024 so
AggregateErroris available; the choice is about APIshape: a typed
failuresarray carrying{ name, error }per release isclearer for callers than
AggregateError's untypederrors.intentionally differ (poisoning vs. recovery) and would be silently
changed by a generic per-key serializer. Inline comments now document
why.
Cancellation follow-up
The plan calls for a separate GitHub issue threading
AbortSignalthrough host wait/poll paths. Filed as #84 with theready-for-agentlabel.Validation
Plus the renderer-forced manual smoke from the plan, against an isolated
AGENT_TTY_HOME, with apsbaseline:ADRs
docs/adr/0001-use-release-it-only-for-release-prep.mdto0002-...to resolve the duplicate
0001numbering.docs/adr/0003-renderer-dispose-swallows-cleanup-failures.mddocumenting the best-effort dispose contract and the per-lifecycle scope
decision.
Files
src/util/resourceScope.ts,src/util/keyedSerializer.ts(+ unit tests)src/storage/artifactManifest.ts— usesKeyedSerializer<string>src/renderer/ghosttyWeb/backend.ts— per-lifecycleResourceScope,closeResourceScopeAndLog()src/host/eventLog.ts,src/host/hostMain.ts— clarifying comments onlytest/unit/storage/artifactStorage.test.ts— concurrent-writer regressiontest/unit/renderer/ghosttyWebBackend.test.ts— dispose-cleanup logging testdocs/adr/0002-use-release-it-only-for-release-prep.md(renamed)docs/adr/0003-renderer-dispose-swallows-cleanup-failures.md(new)📋 Implementation Plan
Plan: ResourceScope + KeyedSerializer Refactor
Goal
Address the concrete maintainability issues surfaced during the Effect-library discussion without adopting Effect or rewriting the app.
The agreed approach is targeted, pragmatic, dependency-free helper extraction:
ResourceScopeutility for renderer resource cleanup.KeyedSerializer<K>utility for per-key serialized work.AbortSignalwork into a concrete GitHub issue.Non-goals
hostMain.tscancellation rewrite in this stack.CliErrorremains as-is.CONTEXT.mdchanges; the resolved terms are implementation patterns, not domain language.Evidence and constraints
CONTEXT.md; the current glossary is domain-focused (Session,Event Log,Render Wait,Snapshot Capture, etc.). Utility terms such asResourceScope, serializer, queue, and cleanup policy do not belong there.docs/adr/0001-adopt-oxc-lint-format-tooling.mddocs/adr/0001-use-release-it-only-for-release-prep.md0001; the release-it ADR should be renamed to0002in PR2.gtis installed (1.8.5) but this repo is not yet initialized for Graphite; PR1 prep should rungt repo initbefore creating the stack and verify whether it creates a tracked.graphite_repo_config. If it does, include that file deliberately in PR1 or document why it is local-only.src/renderer/ghosttyWeb/backend.tscurrently performs renderer cleanup viacleanupHandles(), manually nullingpage,browserContext,browser,server, andserverOrigin, then closing resources with repeatedtry/catchblocks that swallow individual cleanup failures.cleanupHandles()is called from both renderer boot-failure rollback and normal dispose.src/storage/artifactManifest.tscurrently uses a module-localappendQueues: Map<string, Promise<void>>with an identity-check.finally()cleanup to serialize manifest appends per session directory.src/host/eventLog.tsandsrc/host/hostMain.tsalso use promise chains, but their semantics are intentionally different and should not be abstracted in this change.Resolved design decisions
ResourceScopecollects release failures and throwsResourceScopeCloseErrorfromclose().KeyedSerializer<K>is extracted; EventLog and PTY queues stay inline with comments.ResourceScopeAPI:add(name, release)+close().close()returns the same in-flight/completed result;add()after close throws.KeyedSerializer<K>.run<T>(key, op)is generic and preserves current cascading-failure semantics.dispose()remains best-effort:ResourceScopeCloseErrorfailures are logged and swallowed at the public boundary.this.logger.warn, notconsoleor the event log.ResourceScope; accept tighter post-completion dispose semantics.0002in PR2; new renderer dispose ADR becomes0003.Architecture notes
ResourceScopeAdd
src/util/resourceScope.tswith a minimal class and named close error:Required semantics:
close()rejects withResourceScopeCloseError.ResourceScopeCloseErrorpreserves both the original errors and their resource names via afailuresarray, so callsites can logfailure.nameandfailure.errorwithout parsing the error message.close()is idempotent and returns the same promise/result for concurrent or later callers.close().add()after close throws via the repo's existing invariant/assertion helper.AggregateError. If not, makeResourceScopeCloseErrorextendErrorwhile keeping the samefailuresAPI; do not changetsconfigjust for this helper.KeyedSerializer<K>Add
src/util/keyedSerializer.tswith a minimal class:Required semantics:
Operations for the same key run sequentially.
Operations for different keys may run concurrently.
The per-key chain is removed using the same identity-check cleanup pattern currently in
appendArtifact().Preserve the current cascading-failure semantics: if an earlier operation in a still-active chain rejects, later queued operations in that same chain inherit the rejection and do not run.
Return the
.finally(...)-tracked promise, not the pre-finallypromise, so the tracked chain cannot reject unobserved.The implementation shape should be:
After the chain drains and the map entry is removed, a later operation for the same key starts fresh, including after a rejection.
The helper should include a short code comment explaining why the single-argument
.then(() => operation())is intentional.Renderer dispose policy
Refactor
src/renderer/ghosttyWeb/backend.tsso cleanup registration happens at acquisition time:Add a class-level
ResourceScopefield.Keep public
dispose()memoized with adisposePromise, but do not reset that promise after completion:This prevents concurrent dispose races and avoids re-running dispose after completion.
Memoize the scope-close/log helper separately so boot-failure rollback and a later public
dispose()do not re-log the same cachedResourceScopeCloseError:Before committing to the single class-level scope, implementation must inspect callers/tests to confirm no second lifecycle is supported after either
dispose()or boot failure. If rebooting a disposed/failed backend is supported or tested, switch to a per-lifecycle scope instead.After acquiring
server, registercloseServer(server).After acquiring
browser, registerbrowser.close().After acquiring
browserContext, registerbrowserContext.close().After acquiring
page, registerpage.close()guarded by!page.isClosed().Release closures must capture local resource variables (
server,browser,browserContext,page) rather than reading mutablethis.*fields.ResourceScopehandles release only; it does not reset renderer state. Explicitly null/resetthis.page,this.browserContext,this.browser,this.server,this.serverOrigin, andthis.isBootedon both boot-failure rollback and normal dispose.bootInternal()failure rollback closes the scope through a helper that catches/logsResourceScopeCloseError, preserves the bootfailureReason, and then rethrows the boot failure.dispose()closes the same scope through that helper and then performs the existing state reset except it must not cleardisposePromise.Public dispose behavior stays best-effort: release errors are logged and swallowed.
The previous redundant behavior where
dispose()could re-run post-completion is intentionally tightened: subsequent dispose calls reuse the same completed dispose promise.Renderer dispose logging
Add a private helper such as
closeResourceScopeAndLog():await this.resourceScope.close().ResourceScopeCloseError.failureviathis.logger.warnwithfailure.nameandfailure.error.console.error.Queue/serializer callsites
Refactor only
src/storage/artifactManifest.ts:appendQueuesMap and inline queue management with a module-localKeyedSerializer<string>.appendArtifact()should callappendSerializer.run(resolvedSessionDir, async () => { ... }).Do not abstract these queues:
src/host/eventLog.tswrite queue: intentionally poisoning, because event-log sequence gaps would break canonical replay truth.src/host/hostMain.tsPTY ingestion queue: intentionally recovers after failure and runs operations despite predecessor rejection.Add concise comments at those two sites documenting why they remain explicit.
Stack plan
PR1: Utility helpers
Branch created with Graphite after initializing the repo if needed.
Contents:
src/util/resourceScope.tssrc/util/keyedSerializer.tstest/unit/util/resourceScope.test.tstest/unit/util/keyedSerializer.test.tsAcceptance criteria:
ResourceScopetests prove:ResourceScopeCloseErrorincludes original errors and failed resource names.close()callers receive the same result.close()after completion returns the same result and does not re-run releases, including after failed releases.add()after close throws.KeyedSerializertests prove:Validation gate:
mise run format-check mise run lint mise run typecheck npm run test -- test/unit/util/resourceScope.test.ts test/unit/util/keyedSerializer.test.ts mise run ciPR2: Callsites, ADR, and follow-up issue
Stacked on PR1 with Graphite.
Contents:
src/renderer/ghosttyWeb/backend.tsto useResourceScope.src/storage/artifactManifest.tsto useKeyedSerializer<string>.test/unit/storage/artifactStorage.test.ts.docs/adr/0001-use-release-it-only-for-release-prep.mdtodocs/adr/0002-use-release-it-only-for-release-prep.md.docs/adr/0003-renderer-dispose-swallows-cleanup-failures.md.Acceptance criteria:
dispose()or boot failure; if it is supported/tested, the renderer uses a per-lifecycle scope instead of a single class-level scope.dispose()still resolves successfully when cleanup releases fail, but now logs the failures once, including if boot-failure rollback already closed/logged the scope.appendArtifact()remains serialized per session directory.0002and the new renderer dispose ADR is0003.ready-for-agent.Validation gate:
mise run format-check mise run lint mise run typecheck npm run test -- test/unit/storage/artifactStorage.test.ts mise run ciManual smoke / dogfooding gate:
Use an isolated home and avoid mutating
~/.agent-tty. The smoke must include a renderer-backed operation (snapshotorscreenshot), becausecreate/runalone may not bootGhosttyWebBackend. Explicitly forceghostty-webwith the global--renderer ghostty-weboption, or first verify it is already the default in the current workspace.Capture a process baseline first so the orphan check does not fail on unrelated developer/CI browser processes.
Expected result:
create,snapshot,screenshot,destroy, andgcsucceed and emit valid JSON.snapshot/screenshotexercise theghostty-webrenderer path that ownsGhosttyWebBackendcleanup.No dogfood proof bundle is required because this is an internal refactor with focused helper tests and full CI/e2e coverage. If smoke testing reveals a renderer lifecycle regression, stop and either add a targeted renderer regression test or split the renderer refactor into a smaller follow-up.
Cancellation follow-up issue draft
Create a GitHub issue during PR2 prep, after PR1 has landed or while PR2 is stacked on it.
If
ghauthentication or labels are unavailable during implementation, do not block PR2. Instead, include this issue draft in the PR description or final implementation summary and call out the filing blocker.Suggested title:
Suggested labels:
ready-for-agentSuggested body:
ADR 0003 draft
Suggested file:
docs/adr/0003-renderer-dispose-swallows-cleanup-failures.mdAdvisor review incorporated
Advisor feedback was folded into this plan before approval:
KeyedSerializernow returns the tracked.finally(...)promise to avoid unobserved rejections.ResourceScopenow exposes a namedResourceScopeCloseErrorwith resource-name-preservingfailures.dispose()keeps a memoizeddisposePromiseand does not reset it after completion.disposePromiseexception and bootfailureReasonpreservation.ghostty-web, includessnapshotandscreenshot, and compares against a pre-smoke process baseline.Risks and mitigations
ResourceScopechanges renderer dispose behavior unexpectedly.console; respect log-level plumbing.KeyedSerializeraccidentally changes manifest append semantics.gt repo initbefore creating the stack; check whether.graphite_repo_configis tracked and include/document it deliberately; avoid manual branch surgery unlessgtfails.Handoff notes for implementation mode
.jsimports.src/util/assert.ts.AGENT_TTY_HOMEfor all CLI smoke testing.gtfor the stack and include the required mux-generated footer in PR bodies after checking$MUX_MODEL_STRINGand$MUX_THINKING_LEVEL.Generated with
mux• Model:anthropic:claude-opus-4-7• Thinking:max