Skip to content

fix(native-chat): resume structured chats cleanly after restart - #20509

Merged
brennanb2025 merged 9 commits into
mainfrom
brennanb2025/fix-hide-provider-exited
Sep 14, 2026
Merged

brennanb2025 merged 9 commits into
mainfrom
brennanb2025/fix-hide-provider-exited

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 17 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1537 $\color{#cf222e}{\Huge{\mathbf{−}}}$​114 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1423
Prod 19 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​668 $\color{#cf222e}{\Huge{\mathbf{−}}}$​215 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​453

ELI5

Restarting Orca no longer turns a resumable structured conversation into a scary provider/PID error. An idle conversation reopens quietly and stays sendable. If a provider really stops during an active response, the conversation still says so — and now says why.

Why

Quit never released the durable lease. The lease kept naming a provider child that the quit had already killed, so the next launch probed that recorded pid, found it absent, adjudicated the session evicted, and wrote a synthetic status row into the transcript of a chat that was perfectly resumable.

That is a lifecycle-ownership bug, not a presentation bug. The host owned the child; nothing handed the lease back when it stopped one.

What Changed

Two things were genuinely missing. The rest of the retirement sequence — gate acquisitions, drain in-flight work, stop the child, settle the journal, release, close — already existed on main as the per-session eviction step list; it simply was never run on the quit path, and its release had no fence check.

  • Add the missing quit phase. Host teardown now runs evict-owned-sessions, which puts every provider child this host owns through that existing eviction sequence. Because that eviction is what stops the children, it runs before the adapter backstop closes rather than after, so each stop is a real receipt instead of a vacuous one from an already-closed router. The phase is bounded well below the global quit deadline; a quit that dies mid-eviction would leave the lease unreleased, which is the original bug.
  • Add the missing fence check. The lease release now compare-and-swaps on the fence the host expected, so a stale release cannot land on a newer generation.

With those in place, restart finds a released lease and reopens silently.

What the user sees is now decided by the typed death evidence rather than by the shape of a settlement id:

  • Only an exit-observed death writes user-facing copy. A pid-absent or identity-mismatch adjudication — every unclean restart — writes none.
  • That copy carries its cause again, so an auth failure and an OOM kill no longer read identically: The provider stopped while this response was in progress: <cause>. You can continue in this conversation.
  • "In progress" now means a running turn, a running tool call, or an unsettled submission. A provider that died while waiting on an approval interrupted nothing, and no longer claims it did.

This is a fix forward. Once the lease is released correctly, no new row of this kind is written. Rows an earlier build already persisted are left exactly as they are: they stay in those transcripts and age out with the conversations that hold them. Nothing hides them at read time, nothing rewrites them, and there is no data migration or journal schema change — bumping the schema would latch older builds read-only on any journal a newer build had opened, which renders the whole transcript empty and unwritable.

A ratchet test scans the shipped tree so no future producer can resurrect the retired copy. With nothing filtering that string on the way out, such a row would land in the user's transcript directly, which is the reported symptom.

Smaller items in the same area: host bookkeeping clears its provider-child flag when the child is proven stopped rather than seven steps later; the settlement failure a quit reports carries its real cause; a recovery-ticket field hardcoded at its only construction site is gone; and the adapter router drops a duplicate shutdown gate and acquisition drain (both adapters already own theirs, and the retry-until-drained close loop already covers the second) while keeping its close memoization, its per-session stop receipt, and a new closed latch so a late acquire cannot fan a session back out to closed adapters.

Linked Issue

N/A — user-reported regression; no issue was supplied.

Visual Proof

Before: reopening a structured native chat after restart could add Provider exited: recorded PID absent on host to the transcript.

After: the same structured conversation reopens without the internal diagnostic and accepts another turn.

orca-stargazer-after-restart-send.png

Testing

  • pnpm test src/main/native-chat src/main/runtime — 919 files passed, 3 skipped; 9014 tests passed, 31 skipped
  • pnpm tc
  • pnpm run check:code-quality:changed — zero findings across all changed-code checks, re-run after commit
  • pnpm format
  • Ablations, each re-run at the final head:
    • reverting the settlement path to main, so the settlement-id string sniff decides the copy again, fails the idle-restart guard (3 of 21 in the surface-lifetime suite, including settles an idle absent owner without chat pollution) and the evidence-discriminator tests (5 of 13, including reads the evidence, not the settlement id, when deciding to speak)
    • making the interruption check unconditional again fails the idle-approval tests (4 of 24)
    • planting a literal that opens with the retired copy in a production file fails the copy ratchet (1 of 5); removing it restores green
  • Hidden/background macOS Electron validation through CDP with a real structured Codex conversation. This run predates the removal of the read-time filter, so the "no status row" observation below is now carried by the unit guard and its ablation above:
    • completed RESTORE_READY
    • restarted and reopened with the same session, journal epoch, and provider thread
    • rendered no Provider exited or recorded PID absent on host message
    • kept the composer usable and completed a second turn, STILL_SENDABLE

Not yet exercised: packaged quitAndInstall, Claude structured chat, SSH, Windows, or Linux. The Electron run covered local macOS Codex and normal restart teardown/reopen.

Review

  • Functional correctness: idle restarts stay quiet and resumable; a genuine in-progress interruption stays visible and now names its cause.
  • Architectural fit: provider status and teardown remain execution-host-owned; readers do not infer process death. Per-session stop receipts bridge teardown to durable release, and a global close marks each routed session stopped rather than clearing the table, so the router reports a stop only for a session it actually acted on — a lookup miss is loss of contact, never proof the child exited.
  • Backward compatibility: no journal schema change, no data migration, and no wire change. The read path is unchanged; rows an earlier build already wrote keep rendering as they always did.
  • Performance: no per-open write transaction and no legacy transcript scan on the startup restore path, and no added work on the read path.

Agent skill upstream boundary

  • Not applicable; this change does not copy or translate skill-installer source, tests, fixtures, registries, path tables, comments, or documentation.

Notes

Scoped to native structured-chat provider ownership and journal settlement. No new UI, styling, or wire opcode.

Checklist

  • This PR is focused on the reported restart/resume lifecycle bug
  • I explained what changed and why (including ELI5)
  • Interaction evidence is attached and the before state is documented above
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and backwards-compatibility impact considered; untested environments are disclosed above
  • Full-repository lint, test, and build were not run locally; the native-chat and runtime suites, full typecheck, changed-code quality, formatting, and CI cover the remaining gates

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 6c256288-c75c-4eb4-97e9-295a7ffdfd80

📥 Commits

Reviewing files that changed from the base of the PR and between 41798d5 and 778f56a.

📒 Files selected for processing (4)
  • src/main/native-chat/agent-session-wire/agent-session-retired-provider-exit-status-filter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The change removes generic provider-exit status rows from Codex settlement and filters retired rows during history rendering. It adds dead-generation settlement for unfinished work and interrupted turns. The adapter router now preserves stop proofs and coordinates global shutdown. Host teardown retries owed child wind-down, bounds eviction, validates lease fences, and acknowledges released sessions. Runtime and lifecycle tests cover these paths.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 778f5

The restart regression tests can pass when history loading resets, leaving the intended restored-history behavior incompletely protected. This is a bounded test-coverage risk rather than a demonstrated runtime failure.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.92% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 83 functions across 37 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description explains the problem, implementation, testing, scope, and known limitations. However, it does not provide the required linked issue, does not include both required before and after vis… Provide a valid linked issue instead of N/A, attach both before and after visuals for the behavior change, and complete the AI Disclosure section or state the applicable exemption.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the main change: fixing restart behavior so native structured chats resume cleanly.
Full details: Description check

Explanation

The description explains the problem, implementation, testing, scope, and known limitations. However, it does not provide the required linked issue, does not include both required before and after visual attachments, and omits the AI Disclosure section.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one design question and minor test-coverage notes.

Reviewed changes

  • Retire owned provider children on expected teardown — new evictOwnedStructuredAgentSessions stops every host-owned child, settles it via a new settle-dead-generation eviction step, keeps failed sessions in retainSessionIds for a retry, and drops the adapter's stop proof via acknowledgeSessionRelease. tearDownRuntime now flushes the host (and evicts) before the adapter.closeAll() backstop.
  • Router route/stop-proof state machine — structured-agent-session-adapter-router.ts tracks per-session live/stopped routes, gates acquire while closing, drains in-flight acquisitions before closeAll, and publishes an O(1) allAdaptersClosed global proof.
  • Relocate the unexpected-exit row — removed the unconditional Provider exited: <reason> status from the codex translator; the new dead-generation settlement writes the actionable UNEXPECTED_PROVIDER_EXIT_OUTCOME only when an in-flight response was actually interrupted, and settles expected closes silently.
  • Epoch-scoped tombstone migration (schema v3) — new journal_epoch_migrations table and JournalEpochMigrationRunner atomically tombstone historical synthetic restart-eviction:<session>:<fence> rows once per epoch, without advancing activity or cursors; read-only and future schemas are untouched.
  • Tests — 18 files covering teardown ordering/retry, handoff acknowledgement, restart restore, unexpected exits, migration atomicity/idempotence, and router shutdown races.

I traced the teardown, migration, and unexpected-exit paths and could not construct a leak, a data-integrity defect, or a benign-restart row. The one thing worth a human decision is below.

ℹ️ Probe-proven restart settles a running turn with no user-facing explanation

When a host restarts and the owner probe proves the child gone (pid-absent / identity-mismatch) while the journal still holds a running turn, the retry path forces showUnexpectedExitOutcome: false and turnVerdictFromDeathEvidence yields unverifiable, so the turn is revised but no outcome row is written. This is deliberate and pinned by structured-agent-session-dead-generation-settlement.test.ts:79 ("without a technical chat row or fake end time"), and it is consistent with not fabricating an end time — but it means a crashed response produces a turn that silently changes state, while an observed provider death produces actionable copy. Flagging only so the product intent is explicit.

Technical details
# Silent settlement of probe-proven restart deaths

## Affected sites
- `structured-agent-session-settlement-retry.ts:77-79` — `showUnexpectedExitOutcome` is derived from the `provider-exit:` id prefix, so a `restart-eviction:` latch always passes `false`.
- `structured-agent-session-dead-generation-settlement.ts:129-137` — an `unverifiable` verdict has no status body, only a turn revision.
- `structured-agent-session-dead-generation-settlement.test.ts:79-113` — asserts no `status` item and no end time.

## Open question for the human
- Is a probe-proven crash of an in-flight response supposed to remain completely silent, or should it get a variant of `UNEXPECTED_PROVIDER_EXIT_OUTCOME` (without a fabricated `completedAt`)? If silent is intended, no change is needed.

ℹ️ Nitpicks

  • cloneTombstoneMigrationState (journal-epoch-migration-runner.ts:88-97) shallow-copies the container maps, so cloned submissions/items share their value objects. It is safe today because the runner only ever builds tombstone mutations (which touch only the cloned items/tombstones), but a future item-mutation input would let acceptSubmissionFromProviderItem mutate live state before a ROLLBACK.
  • structured-agent-session-settlement-retry.ts:77-79 reconstructs the show-outcome decision from the settlement id prefix instead of persisting what unfinishedStructuredAgentSessionWorkWasInterrupted originally decided. The invariant that makes it safe holds today; worth a comment if a new latch id is ever added.
  • JournalEpochMigrationRunner.run takes a BEGIN IMMEDIATE write lock on every attach even when the marker already exists (read-only early-return aside) — a small cost on the read path.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Restarting Orca turned a resumable structured chat into a user-visible
`Provider exited: recorded pid absent on host`. Quit never released the durable
lease, so restart probed the recorded pid, adjudicated the session evicted, and
wrote a synthetic status row against a chat that was perfectly resumable.

The fix is the missing teardown phase plus the missing fence check: quit now
evicts every provider child this host owns — stopping it, settling its journal
and handing the lease back — and the release compare-and-swaps on the fence it
expected. Restart then finds a released lease and reopens the chat silently.

What the user sees is decided by the typed death evidence rather than the shape
of a settlement id: only an `exit-observed` death writes copy, and that copy now
carries its cause so an auth failure and an OOM kill do not read alike. The
reassuring wording stays. Historical synthetic rows are filtered out of the
render projection, which needs no schema change and leaves every real
provider-exit row alone.

Also:
- Bound the new eviction phase well below the quit deadline; a quit that dies
  mid-eviction leaves the lease unreleased, which is the original bug.
- Scope the interruption verdict to work that was mid-response. A provider that
  died while waiting on an approval interrupted nothing.
- Keep host bookkeeping in step with the adapter: the provider-child flag clears
  when the child is proven stopped, not seven steps later.
- Drop the router's duplicate shutdown gate and acquisition drain — both
  adapters already own theirs — and latch the router closed so a late acquire
  cannot fan a session back out to closed adapters.
- Attach the real cause to the settlement failure a quit reports, and remove a
  recovery-ticket field that was hardcoded at its only construction site.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: cf96a8b6-c927-4bc7-9b03-ba21585bed50

📥 Commits

Reviewing files that changed from the base of the PR and between ca2356c and 4d13853.

📒 Files selected for processing (34)
  • src/main/codex/codex-structured-journal-settlement.ts
  • src/main/codex/codex-structured-journal-translation-settlement.test.ts
  • src/main/codex/codex-structured-journal-translation.test.ts
  • src/main/codex/codex-structured-session-background-tasks.test.ts
  • src/main/native-chat/agent-session-wire/agent-session-history-page.ts
  • src/main/native-chat/agent-session-wire/agent-session-restart-eviction-status-filter.test.ts
  • src/main/native-chat/agent-session-wire/agent-session-restart-eviction-status-filter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
  • src/main/runtime/agent-session-surface-release-transition.ts
  • src/main/runtime/structured-agent-session-runtime-exit.test.ts
  • src/main/runtime/structured-agent-session-runtime.ts
💤 Files with no reviewable changes (1)
  • src/main/codex/codex-structured-session-background-tasks.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Comment on lines +68 to +80
{
name: 'evict-owned-sessions',
run: () => withPhaseTimeout(collaborators.evictOwnedSessions, CHILD_EVICTION_TIMEOUT_MS)
},
{ name: 'flush-event-sinks', run: () => collaborators.runtimeState.flushAllEventSinks() }
]
}

export async function tearDownStructuredAgentSessionHost(input: {
phases: readonly StructuredAgentSessionTeardownPhase[]
sessions: Map<string, StructuredAgentSessionHostSession>
retainSessionIds?: ReadonlySet<string>
acknowledgeSessionRelease?: (sessionId: string) => void

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Fence timed-out session eviction before adapter shutdown.

If withPhaseTimeout times out, withTimeout does not cancel or join evictOwnedStructuredAgentSessions. Teardown then retains the session journal but continues to installed.adapter.closeAll(). StructuredAgentSessionAdapterRouter.closeAll() delegates to provider closeAll(), which enumerates the same provider session while disposeSession() may still be closing it. This can issue concurrent provider shutdown and settlement operations for one session.

Add a per-session fence that closeAll() honors, or join pending evictions before adapter shutdown.

})
await restarted.restoreReadableSessions()
const history = restarted.history({ sessionId: SESSION, direction: 'tail' })
expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that each history request succeeds.

Both expressions evaluate to false when history.ok is false. The tests then pass without checking the history items.

Assert history.ok first. Inspect history.page.items only after that assertion succeeds.

Also applies to: 395-396

The read-time filter hid every status row carrying a `restart-eviction:`
identity. That identity is still minted, so a genuine provider death settled
under it would have been dropped from every rendered page. Match the retired
`Provider exited` copy as well, so only the legacy rows are hidden.

Three smaller corrections alongside it:

- The settlement retry path now applies the same unfinished-work check the
  live exit path uses, so a provider that died waiting on an approval no
  longer gets told a response was in progress.
- Bound the exit reason before composing the outcome copy, so a stderr dump
  in the reason cannot push the "you can continue" sentence past the row's
  byte cap.
- Correct the teardown comment: tail rows are protected by eviction's own
  per-session ordering, and `closeAll` is a backstop for children eviction
  never took, including one whose eviction was refused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 0df85175-8d5f-4a9d-af98-cf9d1ece6892

📥 Commits

Reviewing files that changed from the base of the PR and between 4d13853 and e0294b1.

📒 Files selected for processing (8)
  • src/main/native-chat/agent-session-wire/agent-session-restart-eviction-status-filter.test.ts
  • src/main/native-chat/agent-session-wire/agent-session-restart-eviction-status-filter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
  • src/main/runtime/structured-agent-session-runtime.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
  • src/main/runtime/structured-agent-session-runtime.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.

* into the copy rather than left in the durable record nothing renders. */
export function unexpectedProviderExitOutcome(reason?: string): string {
const detail = reason
?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Trim the exit reason before applying the bound.

Line 30 slices the raw reason before trim(). If a provider emits 512 leading spaces followed by OOM, detail becomes empty and the status loses the exit cause. Trim first, then apply the bound.

Proposed fix
   const detail = reason
-    ?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS)
-    .trim()
+    ?.trim()
+    .slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS)
     .replace(/[.\s]+$/, '')

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

One regression to fix before merging: clearing session.hasProviderChild at the proven stop (rather than after releaseLease) removes a partially-evicted session from the quit-path retry selection, so a mid-eviction failure can leave the durable lease naming a dead child — the exact condition this PR exists to clear.

Reviewed changes

  • Replaced the epoch tombstone migration with a read-time restart-eviction: status filter applied in buildPage; deleted the migration runner/marker/repair modules, the journal_epoch_migrations table, and reverted JOURNAL_DB_SCHEMA_VERSION to 2.
  • Made the user-facing outcome evidence-driven: unexpectedProviderExitOutcome(reason) carries the cause, the retry derives showUnexpectedExitOutcome from the death-evidence verdict instead of the settlement-id prefix, and isInProgressItem no longer treats a pending approval/question as an interrupted response.
  • Cleared host provider-child bookkeeping at the proven stop step and made a quit's settlement failure carry its real cause.
  • Bounded the evict-owned-sessions quit phase with withPhaseTimeout and prepopulated the retain set so a timed-out eviction does not have its journal closed underneath it.
  • Simplified the adapter router (removed the closing/in-flight drain machinery, made allAdaptersClosed final) and extended the recovery ticket with the released fence, dead acquisition generation, and settlement id.
  • Tests: reworked router shutdown tests, added idle-approval and evidence-discriminator settlement tests, added restart-eviction filter coverage, and added a runtime backstop-exit test.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in this delta — the prior review's teardown-retry finding is still open and should land before merge.

Reviewed changes

  • Confined the legacy restart-eviction: read filter to rows whose copy starts with Provider exited (RETIRED_RESTART_EVICTION_TEXT_PREFIX), so a genuine provider death settled under that same identity with the current outcome copy still renders; a test now pins both sides.
  • Bounded the exit cause with MAX_UNEXPECTED_EXIT_REASON_CHARS = 512 before composing unexpectedProviderExitOutcome, so a stderr dump cannot push the actionable "You can continue…" tail past the row's byte cap; a 4k-frame-reason test asserts the tail survives.
  • Gated the pending settlement retry's showUnexpectedExitOutcome on both verdict.state === 'interrupted' and unfinishedStructuredAgentSessionWorkWasInterrupted, matching the live exit path, so a witnessed exit that interrupted only a waiting prompt writes no copy; a test covers it.
  • Clarified the runtime teardown comment (no behavior change).

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron QA on e0294b1ee2 (worktree qa-20509-electron): structured Codex chat reopened quietly after a clean restart and stayed sendable.

  • Assertion 5 PASS: reopened transcript has no Provider exited and no recorded PID absent on host.
  • Assertion 6 PASS: composer accepted a second turn; it completed (pongping).

After restart (same session, first turn only):

after-restart-transcript.png

Second turn after restart:

second-turn-complete.png

The read-time filter that hides the retired `Provider exited …` rows ran on the
way OUT of the page builder, after the paging math had already measured the
unfiltered timeline. A backward window landing entirely on those rows returned
an empty page that still reported `hasOlder: true` with a null `window.oldest`,
so the renderer's backfill loop re-asked from the same anchor forever. Its only
no-progress guard compares `window.oldest?.sequence` to the anchor, and
`undefined === n` never breaks. The live subscription opens behind that loop, so
the transcript never finished loading either.

Filter where items ENTER the page pipeline instead: the reduced snapshot gets
one renderable timeline, the forward path gets one renderable batch, and the
window bound, effective limit, `hasOlder`, `window.oldest` and `nextCursor` are
all computed over that single array. A window with nothing left behind it now
reports end-of-history.

Also restore the eviction retry contract. Clearing `hasProviderChild` as soon as
the adapter proves the child gone is honest, but it is a different fact from the
wind-down this host still owes. A retry after a step aborted between the two was
reading "no child here" and skipping both the dead-generation settlement and the
lease release the aborted attempt had promised to repeat. The obligation is now
tracked separately and cleared only by a release that actually landed.

And rename the filter to the copy it retires: it drops only rows carrying the
retired `Provider exited` text, not restart-eviction status rows in general.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron QA at 030110389f (supersedes the earlier e0294b1ee2 comment). Structured Codex chat reopened quietly after a clean restart and stayed sendable.

  • Assertion 5 PASS: reopened transcript has no Provider exited and no recorded PID absent on host.
  • Assertion 6 PASS: composer accepted a second turn; it completed (pongping).
  • Assertion 7 PASS: scrolled to the top of the reopened transcript. History terminated at the oldest message (first user prompt). No Load earlier messages / Loading… affordance. Wrapped window.api.runtime.call and counted agentSession.history: 0 calls across 6 samples over ~12s of repeated top-scrolls (probe installed after reopen, so this is post-restore paging). The short transcript already fit in one page (scrollHeight === clientHeight).

After restart (same session):

after-restart-transcript.png

Second turn after restart:

second-turn-complete.png

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Restored the teardown retry contract by tracking the owed provider-child wind-down separately from hasProviderChild (owesProviderChildWindDown), so a step that aborts after the adapter proves the child gone no longer strands the durable lease. Selection includes owed sessions, the settlement step guards on the obligation, and the flag clears only once a release lands; a new test drives a drain-published failure and then a successful retry.
  • Moved the retired-Provider exited read filter to where items enter the page pipeline (renderableTimeline for tail/before, projectRenderableBatch for after), so the window bound, hasOlder, window.oldest and nextCursor are computed over the same filtered array. A backward window landing entirely on retired rows now reports end-of-history instead of returning an empty page that still claims older history — the state that wedged the renderer's backfill loop.
  • Renamed the filter to agent-session-retired-provider-exit-status-filter to reflect that it retires one copy, not the restart-eviction: identity.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

An eviction recorded "nothing owed" whenever it ran over a session with no
provider child of its own, and the retry then read that record in preference
to the child in front of it. A session suspended to an agent terminal is
exactly that shape, and the trip back to native re-acquires into the SAME
session object rather than replacing it, so the next close skipped both the
dead-generation settlement and the lease release — leaving the record claiming
a live owner this host had just stopped, and a pending send unsettled.

The obligation is now derived the way the quit sweep already derived it, from
one shared predicate: a live child always owes a wind-down, and a remembered
`false` only carries the obligation forward, never cancels it.

Also drops a memoization in the history page that could never hit. Its key was
the snapshot's items array, which the reducer rebuilds on every `snapshot()`
call, so each backward page allocated a fresh key; the one reader that does
share a snapshot across pages reads forward and never calls it. The comment
claimed a multi-page read filtered once, which was not true of either path.

Tests: the handoff round trip that strands the lease, and the quit sweep
picking up an eviction whose close retry never came.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run covered the delta from the prior review at 0301103 to 41798d5.

  • Derived the owed wind-down from the live child — evictHeldStructuredAgentSession now computes its obligation through a shared owesProviderChildWindDown(session) predicate (hasProviderChild || owesProviderChildWindDown === true) instead of ?? hasProviderChild. A childless eviction (e.g. over a session a TUI owns) could record a remembered false that then outranked a child re-acquired into the same session object on a handoff back to native, skipping both the dead-generation settlement and the lease release. The quit sweep now shares that one predicate, so the two call sites cannot drift.
  • Removed a history-page memoization that could never hit — renderableTimeline no longer caches on snapshot.items, which the reducer rebuilds on every snapshot(); the old key was fresh per backward page, and the one reader that shares a snapshot across pages reads forward and never calls it. Behavior is unchanged and the misleading "filters once" comment is gone.
  • Tests — added a handoff round-trip test (to-TUI → restart with an unreachable terminal → failed close → to-native handoff → close) that leaves the lease stranded without the predicate fix, plus a quit-sweep test for an eviction whose close retry never came.

I ran structured-agent-session-surface-lifetime.test.ts (21 tests) and agent-session-retired-provider-exit-status-filter.test.ts (5 tests) at 41798d5; all green. Prior Pullfrog threads are resolved, and the fix closes the last open finding from earlier in this PR.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…rdown order

retryUnexpectedExitSettlement, hasUnfinishedStructuredAgentSessionWork and
isRetiredProviderExitStatusItem each have no consumer outside the file that
defines them, so they no longer advertise an external contract.

The quit-path phase list documents its order as load-bearing, but nothing
asserted it. Pin the phase names so evict-owned-sessions cannot drift out of
its slot between drain-attaches and flush-event-sinks.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status

Reviewed across six rounds (adversarial review + readiness checklist at both ends + Electron QA). Every round but the last found a real defect in the round before it, so the diff below is materially different from what was first submitted.

Current head: 778f56a037 — clean to merge.

What changed during review

The journal migration and the JOURNAL_DB_SCHEMA_VERSION 2→3 bump were removed entirely. A probe confirmed the bump was a one-way door per profile: once a v3 build opened a journal, an older build latched it read-only and replayJournal returned emptyReadOnlyLoad — zero items, epoch: ''. Every structured transcript would render empty and unwritable after any rollback, and that cost bought only the removal of a cosmetic status row.

It is replaced by a read-time filter. src/main/native-chat/agent-session-journal/ is now byte-identical to main — no schema change, no new table, no marker lifecycle, no epoch-clear hooks, and no write transaction on the startup restore path. Durable history is untouched; the rows are hidden at projection, not deleted.

Two defects found and fixed in the replacement:

  1. Unbounded renderer RPC loop (P1). The filter was first applied in buildPage, after the paging math — hasOlder/nextCursor came from the unfiltered slice while items/window.oldest came from the filtered one. A window of entirely-retired rows returned items: [], window.oldest: null, hasOlder: true, which defeats the backfill loop's only no-progress guard (undefined === n). Measured: 12+ identical agentSession.history requests vs. 4 that terminate. open() never runs either, so the session never subscribes. Fixed by filtering at the projection source so every bound is computed over one array.
  2. Sticky wind-down flag (P2). owesProviderChildWindDown ?? hasProviderChild used ??, which only falls back on undefined, while the flag was written false on every childless eviction. After a handoff round-trip the lease was left at claimStatus: "live" naming a process the host had just stopped. Both call sites now share one predicate so the two readings cannot drift.

Also: the string-sniffing settlementRetryId.startsWith('provider-exit:') discriminator was replaced with the typed deathEvidence check; the filter was scoped to the retired copy so a genuine future death under the same identity is not swallowed; the exit reason is bounded to 512 chars before composition so the actionable tail is never clipped; the retry path now applies the same "was anything actually in progress" rule as the live path; and the new quit phase is bounded at 8s against the 20s quit deadline.

Evidence

  • Original symptom, two-lever ablation: killing both the outcome gate and the copy change fails 13 tests across 4 files. With both files at main, the tests reproduce the literal reported string Provider exited: recorded PID absent on host.
  • P1: reverting only the projection change makes a faithful replay of the renderer backfill loop hit its request budget — the wedge, reproduced on demand.
  • P2: reverting the predicate at either call site fails its own test; both reverted fails both.
  • Genuine death still speaks: removing the retired-copy prefix check fails the test asserting a real provider death still renders.
  • Electron QA (isolated worktree, CDP, hidden renderer): structured Codex chat reopened quietly after a clean restart with no Provider exited / recorded PID absent on host, composer accepted a second turn, and scrolling to the top terminated with 0 further agentSession.history calls. Screenshots in the comments above.
  • Gates: pnpm tc clean; check:code-quality:changed 0 findings across 35 files on all five sub-gates; broad sweep 1093 files / 10872 tests, 0 failures.

Known limits

  • Validation covered local macOS + Codex. Not exercised: packaged quitAndInstall, the Claude adapter, Windows, or Linux. Structured native chat is gated to the local execution host, so SSH is not applicable.
  • The filter runs on the host's build, so an older remote Orca server still serves the legacy rows to a newer client. Inherent to where the fix lives.
  • The Electron transcript fit in one page, so the QA confirms the happy path rather than exercising the paging boundary; the P1 coverage is the unit-level backfill replay.

Follow-ups (not blocking, deliberately out of scope here)

  • The 8s evict-owned-sessions bound abandons rather than cancels the in-flight eviction. State stays consistent — the session stays indexed, the lease stays unreleased, and the next teardown is a real retry — but the timed-out eviction's tail steps run unsupervised after teardown returns. Cancellation is a design question, not a patch.
  • The "eviction latches are never paired with exit-observed evidence" invariant is still unpinned by a test; the retired-copy prefix check mitigates it.

`closeAll` cleared the route table and set one boolean, after which that boolean
was the only surviving evidence about any session. Two call sites then spent it:
`releaseAcquisition` and the stop path each turned a route-lookup MISS into
reported success. Eviction reads a `true` from the stop path as proof the
provider child is gone and releases the durable lease on it, so a session the
router never routed could have its lease handed back on the strength of "I have
no record, but everything is closed."

Loss of contact is not evidence of process death. The fix keeps the evidence
instead of the inference: adapter shutdown only resolves once every child is
proven stopped, so `closeAll` now marks each routed session `stopped` rather
than forgetting it. A routed session still answers `true` from its own retained
proof; a session with no route answers `false`, which leaves it indexed for a
real retry. `releaseAcquisition` drops its short-circuit and asks the adapters,
which answer from their own session maps.

The acquire-side latch is unchanged: once closed, the router stays closed and
refuses new work.

Behaviour that changed: a post-`closeAll` stop for a session the router never
routed, or one the host already acknowledged as released, now reports unproven
instead of proven. That matches what the same call already answered before
`closeAll`, and no real flow reaches it — quit evicts every owned session before
`closeAll` runs, and eviction only asks the adapter for sessions whose provider
child this host acquired through the router.
…uction

The retirement filter hides a status row on two facts: a restart-eviction item
id and copy that opens with the retired prefix. The identity half is still
minted today, so the filter cannot tell a new producer's row from the legacy row
it exists to hide — any future writer of that copy would be dropped from every
transcript with no trace. Until now that safety property lived only in a doc
comment.

Scan the shipped tree for a string literal that OPENS with the retired prefix,
which is exactly what the filter's `startsWith` reads. Comments are stripped
first, so prose about the retirement is not a producer, and the filter's own
constant is exempt. Tests are excluded: writing the copy is how the filter is
exercised.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Addendum — two follow-ups since the status comment above

Head is now fd9eeb96a0 (the comment above described 778f56a037).

1. The adapter router no longer reports a stop it cannot observe.

closeAll() used to routes.clear() and then set an allAdaptersClosed flag, after which two call sites converted a route-lookup miss into reported success. That asserts "I have no record of this session, but everything is closed, so treat it as stopped" — which is the inference our execution boundary forbids, since loss of contact is never evidence of process death.

It mattered concretely: stop-provider-child throws unless the stop returns true, and only a true lets the sequence reach release-lease. So a miss converted to true would release a durable lease for a session the router had no record of.

closeAll() now marks each route state: 'stopped' instead of clearing the map, so the router answers from real per-session evidence. That mark is proof rather than inference — closeAdapters() resolves only when every adapter's sweep has proven every child stopped. allAdaptersClosed is retained solely for the acquire-side latch, which correctly refuses new work after shutdown.

This also removes an inconsistency that predates the PR: a post-acknowledgeSessionRelease stop previously answered true if closeAll had run and false if it hadn't, for the same logical state. Both now answer false. Two tests asserting the old behaviour were updated, and a new one proves a post-closeAll release for a never-routed session reaches both adapters instead of short-circuiting.

2. The retired-copy filter's safety property is now falsifiable.

The filter hides a row only when it has both a restart-eviction:<sessionId>:<fence> identity and the retired copy prefix Provider exited. That identity is still minted today, so the guarantee "no live producer writes that copy" was carried only by a doc comment. A new ratchet test fails if any production source introduces a string literal starting with that prefix, built on the existing source-scan guard helpers. Verified by planting a violation (fails, naming the file and line) and the same text as a comment (stays green, so prose is not a false positive).

Also corrected: the Review section above previously claimed a successful global close "uses an O(1) proof rather than retaining historical session IDs." Change 1 reverses that, and the body has been updated.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run covered the delta from the prior review at 41798d5 to fd9eeb96 (commits 3bb2ac71 and fd9eeb96).

  • Stopped the router issuing a stop receipt it never observed — stopSession now returns false for a session with no route instead of answering from the allAdaptersClosed flag, releaseAcquisition fans out to the adapters again rather than short-circuiting on that flag, and closeAll keeps each routed session's stopped proof in the route map instead of clearing it. A retried teardown still gets true for a session the router actually stopped, while an address it never saw gets loss of contact, not proof of a stop.
  • Added a retired-copy ratchet — agent-session-retired-provider-exit-copy-ratchet.test.ts scans every non-test source under src and fails if any production literal opens with Provider exited, since such a row would be silently hidden by agent-session-retired-provider-exit-status-filter.ts whenever it also carries a restart-eviction: identity.

The router change restores the base semantics for an unrouted session (the intermediate commit had briefly returned the global close flag as a receipt). I traced the quit/retry ordering and every production writer of hasProviderChild/routes: evict-owned-sessions runs before closeAll on the first pass, a retried teardown hits the retained stopped route, and an abort-after-proven-stop retry skips the stop step via hasProviderChild === false — so no host-owned child loses its stop receipt or strands its lease. All prior review threads are resolved.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron QA at fd9eeb96a0 (throwaway profile; does not replace the earlier happy-path comments, it closes the paging gap).

Seeded via openAgentSessionJournal + appendItem into the real SQLite journal of a Codex structured session: 921 durable items (660 user/assistant messages, 260 retired Provider exited: recorded PID absent on host rows under restart-eviction:<sessionId>:<fence>, 1 genuine-death status with the current copy). Retired rows: a 220-run (full 200-item page + boundary-straddle) and a 40-run before the genuine-death row.

# Assertion Result
1 Large transcript finishes loading PASS — composer ready, no perpetual spinner
2 Scroll-back reaches true oldest PASS — QA_OLDEST_000 at top; Load earlier messages gone
3 agentSession.history count bounded PARTIAL — see below
4 No retired copy renders PASS — full-height sweep: no Provider exited / recorded PID absent on host
5 Real messages around retired rows render PASS — 330/330 seeded user markers visible (50 oldest + 30 mid + 250 newest). Mid sits next to newest; the 40-run between them is gone
6 Genuine provider-death copy still renders PASS — current outcome text visible under the same identity shape
7 Live Codex turn, clean quit, relaunch, second turn PASS — pingpong then pongping; no retired copy

Assertion 3: window.api.runtime.call is frozen by the preload bridge, so the wrap never attached and I do not have an IPC-level count. During scroll-back the UI did 2 older-page loads (1× Load earlier messages, then 1× scrollTop=0); hasMore cleared, height went 17894 → 39366 → 39446 and stopped; no spinner. That is bounded UI paging, not a measured RPC storm.

Scrolled to oldest:

history-top.png

Genuine death (retired run between QA_MID_029 and QA_NEWEST_000 is not shown):

genuine-death.png

Second live turn after restart:

second-turn-complete.png

Fix forward instead. The lifecycle change in this branch stops any new
`Provider exited: <reason>` row from being written; rows a previous build
already persisted stay in those transcripts and age out with them. A
permanent read-time filter for a cosmetic, shrinking set was not worth its
maintenance cost, and its paging seam was the only place a backward window
could land entirely on hidden rows.

Removes the filter module and its test, restores agent-session-history-page.ts
to its pre-branch form, and drops the tests that only existed to prove the
filter did not over-match or wedge the backfill loop.

The copy ratchet stays and now carries the whole guarantee: with no filter in
front of it, any production writer that resurrects the retired prefix reaches
the user's transcript directly.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run covered the delta from the prior pullfrog review at fd9eeb96 to 6cc4b0f (the single commit 6cc4b0f).

  • Reverted the read-time retired-copy filter — deleted agent-session-retired-provider-exit-status-filter.ts and its test, dropped the renderableTimeline / projectRenderableBatch paging seam, and restored agent-session-history-page.ts to its pre-branch form (verified byte-identical to the merge base). Legacy restart-eviction: rows carrying the bare Provider exited: <reason> copy render again by design.
  • Moved the whole guarantee onto the copy ratchet — agent-session-retired-provider-exit-copy-ratchet.test.ts no longer exempts the deleted filter file; it fails on any production literal opening with Provider exited, and a repo-wide grep confirms no production writer remains.

The src/main/native-chat suite is green (144 files, 1156 tests), and both prior pullfrog threads are resolved.

ℹ️ The PR description still describes the removed read-time filter

The body's "What Changed", "Backward compatibility", and "Testing" prose still claim the legacy rows "are hidden at read time, in the render projection every history page and live batch funnels through", call the filter "permanent", and say the ratchet guards against rows "silently hidden by that filter". 6cc4b0f removed all of that, so the description now contradicts the code on exactly the point a reviewer is asked to trust. Worth updating before merge so the design decision stays legible.

Technical details
# Stale PR description after the read-time-filter revert

## Affected sites
- PR body, "What Changed" — the read-time-hiding paragraphs and the "read-only and permanent" sentence describe `agent-session-retired-provider-exit-status-filter.ts`, deleted in `6cc4b0f`.
- PR body, "Backward compatibility" — "only the render projection drops them" no longer holds.
- PR body, "Testing" — the ablation "removing the read-time filter fails both new projection tests (2 of 2)" is moot; those tests are deleted.

## Required outcome
- The description should state that already-written legacy rows stay visible, and that the copy ratchet is now the sole writing-side guard.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron QA at 6cc4b0f1d1. The read-time Provider exited filter is removed, so this is an unfiltered observation. It supersedes earlier filter-assisted claims (including fd9eeb96a0) that “no Provider exited rendered after restart.”

Fresh Codex structured chat (codex_2a312ca5_e882_4a65_aebe_69bf749dfa1a), throwaway profile. One completed turn, clean quit, reopen; then a second quit/reopen on the same conversation.

  • Assertion 5 PASS (unfiltered): after this run’s restart, transcript has no Provider exited and no recorded PID absent on host. Also no The provider stopped while this response was in progress.
  • Assertion 6 PASS: composer usable; second turn completed (pongping).
  • Two restart cycles PASS: still clean after a second quit/reopen.

Visual: the first turn took ~4m and Codex painted a Reconnecting... 2/5 / codex Details card during that turn, before the first quit. It is still in the transcript after both restarts. That is not a row written by this run’s restart.

After first restart (unfiltered):

after-restart-1.png

Second turn after first restart:

second-turn.png

After second restart (still unfiltered, both turns present):

after-restart-2.png

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