-
Notifications
You must be signed in to change notification settings - Fork 12
Settling state, abort rule, and the race matrix (settle teardown, step 2) #1075
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
a34fd67
16533ed
e7a28de
dc70929
406f7fe
2da52b6
525fc42
f3e4771
f97aa8b
27b8f14
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,9 +62,25 @@ function resolveMergeSettlementScope(pr: PrSummary, snapshot: PrSummary[]): Merg | |
|
|
||
| export function createPrMergeAutoSettlementService(args: { | ||
| db: Pick<AdeDb, "getJson" | "setJson">; | ||
| sessionService: Pick<ReturnType<typeof createSessionService>, "get" | "list" | "settleSessionsWithOutcome">; | ||
| sessionService: Pick<ReturnType<typeof createSessionService>, "get" | "list" | "settleSessionsReportingAborts">; | ||
| emitEvent: (event: PrEventPayload) => void; | ||
| }) { | ||
| /** | ||
| * Sessions whose auto-settle aborted and that have not been seen at rest | ||
| * since. | ||
| * | ||
| * Two earlier gates were wrong. A revision check never re-arms: a turn | ||
| * COMPLETING does not move the settle lifecycle, so the PR would be skipped | ||
| * forever. A time-based cooldown expires while a long turn is still running, | ||
| * so in step 3 the retry would stop the very work that won the race. | ||
| * | ||
| * "At rest" is the actual signal, and it is the same one the canonical settle | ||
| * tier uses: a session is eligible again once it is no longer running, or its | ||
| * runtime has gone idle. It re-arms exactly when the activity ends. | ||
| * Instance-scoped, so it lives as long as the poller. | ||
| */ | ||
| const abortedSessionIds = new Set<string>(); | ||
|
|
||
| /** | ||
| * The currently open or draft PRs in the previous snapshot, so a merge we | ||
| * WATCHED can be told apart from one that was already history when it arrived. | ||
|
|
@@ -129,11 +145,19 @@ export function createPrMergeAutoSettlementService(args: { | |
| // successful pass, so a snapshot that returns early does not silently | ||
| // consume its own evidence. | ||
| const previouslyWatchedPrIds = new Set(previouslyWatchablePrIds); | ||
| // Ids whose merge this pass watched but could not finish filing, because a | ||
| // session became active mid-settle. Kept watchable so the retry can still | ||
| // announce: `watchedItMerge` is what gates the toast, and a merged PR is | ||
| // otherwise dropped from the watchable set at the end of every pass — so | ||
| // without this the retry settles the session silently and the user never | ||
| // learns their PR merged. | ||
| const unfinishedMergePrIds = new Set<string>(); | ||
| const rememberSnapshot = () => { | ||
| previouslyWatchablePrIds.clear(); | ||
| for (const pr of prs) { | ||
| if (pr.state === "draft" || pr.state === "open") previouslyWatchablePrIds.add(pr.id); | ||
| } | ||
| for (const id of unfinishedMergePrIds) previouslyWatchablePrIds.add(id); | ||
| }; | ||
| const settings = getSessionLifecycleSettings(args.db); | ||
| const state = getPrMergeAutoSettlementState(args.db); | ||
|
|
@@ -170,6 +194,7 @@ export function createPrMergeAutoSettlementService(args: { | |
| ); | ||
|
|
||
| const settledSessionIds: string[] = []; | ||
| let abandonedThisPr = false; | ||
| for (const session of rows) { | ||
| const currentSettings = getSessionLifecycleSettings(args.db); | ||
| const currentState = getPrMergeAutoSettlementState(args.db); | ||
|
|
@@ -185,23 +210,52 @@ export function createPrMergeAutoSettlementService(args: { | |
| // session even when it still owns scheduled work, a background task, | ||
| // or another normal settlement blocker. Real activity can unsettle it | ||
| // again, while handledPrIds prevents this PR from filing it twice. | ||
| settledSessionIds.push(...args.sessionService.settleSessionsWithOutcome( | ||
| [session.id], | ||
| `PR #${pr.githubPrNumber} merged`, | ||
| polledAt, | ||
| "pr_merge", | ||
| )); | ||
| // Wait for the activity that won the race to finish. The abort signal is | ||
| // edge-triggered, so a turn that is STILL running never re-trips it; | ||
| // retrying regardless would, in step 3, stop the very work that beat the | ||
| // first attempt. Gating on "at rest" defers the merge without ever | ||
| // abandoning it. | ||
| if (abortedSessionIds.has(session.id)) { | ||
| const current = args.sessionService.get(session.id); | ||
| const status = (current?.status ?? "").toLowerCase(); | ||
| const runtime = (current?.runtimeState ?? "").toLowerCase(); | ||
| const atRest = status !== "running" || runtime === "idle"; | ||
| if (!atRest) { | ||
| abandonedThisPr = true; | ||
| continue; | ||
| } | ||
| abortedSessionIds.delete(session.id); | ||
| } | ||
| const settleResult = args.sessionService.settleSessionsReportingAborts([session.id], { | ||
| outcome: `PR #${pr.githubPrNumber} merged`, | ||
| settledAt: polledAt, | ||
| source: "pr_merge", | ||
| }); | ||
| settledSessionIds.push(...settleResult.settled); | ||
| if (settleResult.aborted.length) { | ||
| // The session became active while the settle was in flight. Leaving | ||
| // the PR unhandled is the point: a later pass retries, instead of this | ||
| // merge being consumed by a settle that never landed. Hold the retry | ||
| // until the session is seen at rest. | ||
| abandonedThisPr = true; | ||
| abortedSessionIds.add(session.id); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const finalSettings = getSessionLifecycleSettings(args.db); | ||
| // An abandoned settle must not consume the merge. `handledPrIds` is the | ||
| // only thing that would stop a later pass from retrying, and the whole | ||
| // reason the outcome is typed is so this branch can exist. | ||
| if (abandonedThisPr) unfinishedMergePrIds.add(pr.id); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a settle aborts because a turn started or attention was requested, retaining the PR here makes the next snapshot retry unconditionally. Candidate selection at lines 174-177 deliberately ignores active-turn/background-work blockers, while the abort signal is edge-triggered and will not fire again merely because that same turn is still running. Once step 3 attaches real teardown, the next poll can therefore stop the work that won the first race; retain the merge without retrying until the session is no longer active. Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a PR has multiple candidate sessions and an early session settles but a later one aborts, this keeps the PR watched and unhandled while the nonempty Useful? React with 👍 / 👎. |
||
| const finalState = getPrMergeAutoSettlementState(args.db); | ||
| // Mark this PR handled even when its session had background work, and | ||
| // even when the scope came back `ambiguous` and nothing was filed at all: | ||
| // the merge itself is the explicit override, this one looked and decided, | ||
| // and a later user reactivation belongs to a new lifecycle rather than to | ||
| // this already-consumed merge. | ||
| if ( | ||
| finalSettings.autoSettleLaneSessionsOnPrMerge | ||
| !abandonedThisPr | ||
| && finalSettings.autoSettleLaneSessionsOnPrMerge | ||
| && finalState?.enabledSince | ||
| && !finalState.handledPrIds.includes(pr.id) | ||
| && isMergeAtOrAfter(pr.mergedAt, finalState.enabledSince) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import fs from "node:fs"; | ||
| import type { AdeDb } from "../state/kvDb"; | ||
| import { createSettleLifecycleWriter } from "./settleLifecycleWriter"; | ||
| import type { SettleAbortedSession, SettleSessionsOutcome, SettleTeardownCompleted } from "./settlingStateRegistry"; | ||
| import type { | ||
| ClaudeSessionPointer, | ||
| SessionAttentionSource, | ||
|
|
@@ -365,7 +366,21 @@ function normalizeSessionIds(sessionIds: string[]): string[] { | |
| )); | ||
| } | ||
|
|
||
| export function createSessionService({ db }: { db: AdeDb }) { | ||
| export function createSessionService({ | ||
| db, | ||
| runSettleTeardown, | ||
| }: { | ||
| db: AdeDb; | ||
| /** | ||
| * Stop the session's background work. Injected rather than per-call: teardown | ||
| * is a service capability, not something a caller decides. | ||
| * | ||
| * Step 2 ships with this absent — the settling window, the abort rule, and the | ||
| * revision guard all land and are tested against a NO-OP, so every race is | ||
| * exercised before there is any work to lose. Step 3 supplies the real one. | ||
| */ | ||
| runSettleTeardown?: (sessionId: string) => SettleTeardownCompleted; | ||
| }) { | ||
| const changeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); | ||
|
|
||
|
|
||
|
|
@@ -718,6 +733,68 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| return newlySettled; | ||
| }; | ||
|
|
||
| /** | ||
| * Settle through the settling window: the shape a real teardown will run in. | ||
| * | ||
| * Per session: read the revision, open the window, run teardown, then apply | ||
| * the settle ONLY if nothing moved. "Nothing moved" is two checks that catch | ||
| * different things — the abort flag (a human decision arrived and said so) and | ||
| * the revision (anything else changed the settle tuple, including a change | ||
| * this host did not make through a caller). | ||
| * | ||
| * Teardown is a NO-OP in step 2 by design. The point of landing the window | ||
| * first is that every race is testable before there is any work to lose. | ||
| */ | ||
| const settleManyWithTeardown = ( | ||
| sessionIds: string[], | ||
| options: { outcome?: string; settledAt?: string; source?: SessionSettleSource } = {}, | ||
| ): SettleSessionsOutcome => { | ||
| const ids = normalizeSessionIds(sessionIds); | ||
| const settled: string[] = []; | ||
| const aborted: SettleAbortedSession[] = []; | ||
|
|
||
| for (const id of ids) { | ||
| const revisionBefore = settleLifecycle.readRevision(id); | ||
| const begin = settleLifecycle.settling.begin(id, revisionBefore); | ||
| // Joined an in-flight settle rather than starting a second teardown: R4. | ||
| // The owner will report the outcome; reporting it twice would double-count. | ||
| if (begin.kind === "joined") { | ||
| // Report it. A joiner that returns nothing looks identical to a settle | ||
| // that was never eligible, and a caller with a durable consequence — the | ||
| // PR poller marking a merge handled — would consume the merge on the | ||
| // strength of someone else's in-flight settle that may yet abort. | ||
| aborted.push({ sessionId: id, reason: "joined_in_flight" }); | ||
| continue; | ||
| } | ||
| try { | ||
| runSettleTeardown?.(id); | ||
|
|
||
| const abortedBy = settleLifecycle.settling.abortedBy(id); | ||
| if (abortedBy) { | ||
| aborted.push({ sessionId: id, reason: abortedBy }); | ||
| continue; | ||
| } | ||
| // The revision catches everything the abort flag cannot: a settle-tuple | ||
| // change from a path that never announced itself as a decision. | ||
| if (settleLifecycle.readRevision(id) !== revisionBefore) { | ||
| aborted.push({ sessionId: id, reason: "lifecycle_changed" }); | ||
| continue; | ||
| } | ||
| settled.push(...settleMany([id], options)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a sibling ADE process or paired desktop applies an unsettle or keep-active override after the local revision check, that replicated tuple write bypasses this host's lifecycle revision, and this subsequent Useful? React with 👍 / 👎. |
||
| } catch (error) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| // A throw must not discard the accounting for the rest of the batch, nor | ||
| // leave earlier sessions settled with no record of it. Report this id and | ||
| // carry on: a bulk caller always gets a full accounting. | ||
| aborted.push({ sessionId: id, reason: "teardown_failed" }); | ||
| void error; | ||
| } finally { | ||
| settleLifecycle.settling.end(id); | ||
| } | ||
| } | ||
|
|
||
| return { settled, aborted }; | ||
| }; | ||
|
|
||
| return { | ||
| list, | ||
|
|
||
|
|
@@ -1307,7 +1384,7 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| return; | ||
| } | ||
| writeSettleLifecycle({ | ||
| intent: { kind: "clearOnActivity" }, | ||
| intent: { kind: "clearOnActivity", cause: "mechanical" }, | ||
| extraSet: { last_output_preview: preview, last_output_at: now }, | ||
| sessionIds: [sessionId], | ||
| }); | ||
|
|
@@ -1333,7 +1410,7 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| return; | ||
| } | ||
| writeSettleLifecycle({ | ||
| intent: { kind: "clearOnActivity" }, | ||
| intent: { kind: "clearOnActivity", cause: "mechanical" }, | ||
| extraSet: { last_output_at: at }, | ||
| sessionIds: [sessionId], | ||
| }); | ||
|
|
@@ -1432,22 +1509,26 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| sessionId: string, | ||
| opts: { outcome?: string | null; settledAt?: string; source?: SessionSettleSource } = {}, | ||
| ): boolean { | ||
| const settledAt = normalizeIsoTimestamp(opts.settledAt) ?? new Date().toISOString(); | ||
| const outcome = normalizeSessionStatusNote(opts.outcome); | ||
| return mutateSessionMeta(sessionId, (id) => { | ||
| // An explicit settle also drops a stale keep-active pin — otherwise the | ||
| // override would silently veto the settle the user just asked for. | ||
| writeSettleLifecycle({ | ||
| intent: { kind: "settle", settledAt, source: opts.source ?? "user" }, | ||
| extraSet: { | ||
| ...(outcome ? { status_note: outcome } : {}), | ||
| attention_requested_at: null, | ||
| attention_message: null, | ||
| attention_source: null, | ||
| }, | ||
| sessionIds: [id], | ||
| }); | ||
| // Through the settling window, like the bulk paths. This is the route a | ||
| // USER takes (row menu -> settleTerminalSession -> here), which is the | ||
| // "user settle" R4 names — so it has to be joinable and abortable, and in | ||
| // step 3 it has to run teardown. Routing it here is what makes the R4 | ||
| // claim true rather than only true of bulk callers. | ||
| const trimmed = sessionId.trim(); | ||
| if (!trimmed) return false; | ||
| // `settleMany` returns [] for both "missing" and "already settled", so the | ||
| // boolean contract needs its own existence check to stay honest. | ||
| const exists = db.get<{ present: number }>( | ||
| "select 1 as present from terminal_sessions where id = ? limit 1", | ||
| [trimmed], | ||
| ); | ||
| if (!exists) return false; | ||
| settleManyWithTeardown([trimmed], { | ||
| outcome: normalizeSessionStatusNote(opts.outcome) ?? undefined, | ||
| settledAt: opts.settledAt, | ||
| source: opts.source, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Useful? React with 👍 / 👎. |
||
| }); | ||
| return true; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the teardown callback triggers AGENTS.md reference: AGENTS.md:L35-L35 Useful? React with 👍 / 👎.
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }, | ||
|
|
||
| /** Clears a declared settle plus any `'settled'` override. */ | ||
|
|
@@ -1511,16 +1592,28 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| }, | ||
|
|
||
| settleSessions(sessionIds: string[]): string[] { | ||
| return settleMany(sessionIds); | ||
| return settleManyWithTeardown(sessionIds).settled; | ||
| }, | ||
|
|
||
| settleSessionsWithOutcome( | ||
| /** | ||
| * Settle, reporting abandoned sessions explicitly. | ||
| * | ||
| * `settleSessions` leaves an aborted id simply absent from its changed-id | ||
| * list, which is *almost* the right contract — a caller cannot tell "filed" | ||
| * from "not filed, and here is why". This is that distinction, and it is | ||
| * what a caller with a durable consequence (the PR-merge auto-settle marking | ||
| * a PR handled) has to branch on. | ||
| */ | ||
| settleSessionsReportingAborts( | ||
| sessionIds: string[], | ||
| outcome: string, | ||
| settledAt: string = new Date().toISOString(), | ||
| source: SessionSettleSource = "user", | ||
| ): string[] { | ||
| return settleMany(sessionIds, { outcome, settledAt, source }); | ||
| options: { outcome?: string; settledAt?: string; source?: SessionSettleSource } = {}, | ||
| ): SettleSessionsOutcome { | ||
| return settleManyWithTeardown(sessionIds, options); | ||
| }, | ||
|
|
||
| /** Sessions currently mid-settle, for the visible `Settling…` state. */ | ||
| settlingSessionIds(): string[] { | ||
| return settleLifecycle.settling.settlingSessionIds(); | ||
|
Comment on lines
+1659
to
+1661
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Find IPC, preload, shared-type, and renderer references to the settling projection.
rg -nP -C3 '\bsettlingSessionIds\b|\bisSettling\b|Settling…' apps/desktop/srcRepository: arul28/ADE Length of output: 6057 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- candidate desktop files ---'
git ls-files apps/desktop/src | rg '(^|/)(ipc|preload|renderer|session|sessions|.*Service|.*service)' | head -200
printf '%s\n' '--- session service structure ---'
if command -v ast-grep >/dev/null 2>&1; then
ast-grep outline apps/desktop/src/main/services/sessions/sessionService.ts
else
rg -n 'export|settlingSessionIds|sessionService|ipcMain|handle|sessions' apps/desktop/src/main/services/sessions/sessionService.ts
fi
printf '%s\n' '--- session service references outside tests ---'
rg -n -P -C3 '\bsessionService\b|\bsettleSessionsReportingAborts\b|\bsettlingSessionIds\b' apps/desktop/src --glob '!**/*.test.ts' --glob '!**/*.spec.ts'
printf '%s\n' '--- IPC/preload session references ---'
rg -n -P -C3 'session|settle|Settling|isSettling' apps/desktop/src/main apps/desktop/src/preload apps/desktop/src/renderer --glob '*.{ts,tsx}' | head -400Repository: arul28/ADE Length of output: 50367 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- exact settling references in all tracked desktop source ---'
rg -n -P -C2 '\bsettlingSessionIds\b|\bisSettling\b|Settling…' apps/desktop/src --glob '*.{ts,tsx}' || true
printf '%s\n' '--- desktop source top-level directories ---'
find apps/desktop/src -mindepth 1 -maxdepth 2 -type d | sort | head -100
printf '%s\n' '--- IPC registration and preload bridge files ---'
git ls-files apps/desktop/src | rg '(^|/)(ipc|preload|bridge|api|context|channels?)(/|\.|$)' | head -200
printf '%s\n' '--- renderer access to session APIs and status fields ---'
rg -n -P -C2 'window\.(api|electron|desktop)|sessions?\.(list|get|subscribe|on)|terminal\.(list|get|subscribe)|settledAt|settleOverride' apps/desktop/src/renderer apps/desktop/src/preload apps/desktop/src --glob '*.{ts,tsx}' | head -300Repository: arul28/ADE Length of output: 33536 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- session IPC registration ---'
rg -n -P -C8 'session\.list|sessions\.list|sessionService\.list|TerminalSessionSummary|settledAt|settleOverride' apps/desktop/src/main/services/ipc/registerIpc.ts apps/desktop/src/shared apps/desktop/src/preload/preload.ts apps/desktop/src/preload/global.d.ts --glob '*.{ts,tsx}' | head -500
printf '%s\n' '--- session bridge definitions ---'
rg -n -P -C6 'sessions\s*[:=]|list\s*:\s*.*session|onChanged|getDelta|readTranscriptTail' apps/desktop/src/preload/preload.ts apps/desktop/src/preload/global.d.ts apps/desktop/src/shared/ipc.ts
printf '%s\n' '--- session list implementation and summary construction ---'
rg -n -P -C8 'list\(|enrichSessions|TerminalSessionSummary|settledAt|settleOverride' apps/desktop/src/main/services/sessions apps/desktop/src/main/services/pty apps/desktop/src/shared/types --glob '*.{ts,tsx}' | head -500Repository: arul28/ADE Length of output: 50367 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- exact summary type ---'
rg -n -C35 '^export (type|interface) TerminalSessionSummary|^export type TerminalSessionDetail' apps/desktop/src/shared/types/sessions.ts
printf '%s\n' '--- list projection implementation ---'
rg -n -P -C25 'listSessionsWithChatProjection|function listSessionsWithChatProjection|const listSessionsWithChatProjection' apps/desktop/src/main/services/ipc/registerIpc.ts
printf '%s\n' '--- all settling identifiers outside main session internals ---'
rg -n -P '\bsettlingSessionIds\b|\bisSettling\b|Settling…' apps/desktop/src/preload apps/desktop/src/shared apps/desktop/src/renderer || trueRepository: arul28/ADE Length of output: 11137 Expose the
🤖 Prompt for AI AgentsSource: Path instructions |
||
| }, | ||
|
|
||
| unsettleSessions(sessionIds: string[]): void { | ||
|
|
@@ -1677,7 +1770,7 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| ): boolean { | ||
| return mutateSessionMeta(sessionId, (id) => { | ||
| writeSettleLifecycle({ | ||
| intent: { kind: "clearOnActivity" }, | ||
| intent: { kind: "clearOnActivity", cause: "attention_requested" }, | ||
| extraSet: { | ||
| attention_requested_at: new Date().toISOString(), | ||
| attention_message: normalizeOptionalText(message, 500), | ||
|
|
@@ -1706,7 +1799,7 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| // settled/failed mutually exclusive at write time, so every surface's | ||
| // precedence order agrees by construction. | ||
| writeSettleLifecycle({ | ||
| intent: { kind: "clearOnActivity" }, | ||
| intent: { kind: "clearOnActivity", cause: "turn_failed" }, | ||
| extraSet: { last_turn_failed_at: failedAt }, | ||
| sessionIds: [id], | ||
| }); | ||
|
|
@@ -1731,7 +1824,7 @@ export function createSessionService({ db }: { db: AdeDb }) { | |
| clearTurnStartMarkers(sessionId: string): boolean { | ||
| const changed = mutateSessionMeta(sessionId, (id) => { | ||
| writeSettleLifecycle({ | ||
| intent: { kind: "clearOnActivity" }, | ||
| intent: { kind: "clearOnActivity", cause: "turn_start" }, | ||
| extraSet: { | ||
| last_turn_failed_at: null, | ||
| attention_requested_at: null, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For chat sessions, this gate never observes turn completion in production:
sessionService.get()returns the raw persisted row and derivesruntimeStatedirectly fromstatus, while chat rows intentionally keepstatus = "running"between turns; onlychatSessionProjection.tschanges an idle chat toruntimeState = "idle". Thus, after a chat turn aborts auto-settlement, every later poll keeps the merged PR unfinished indefinitely. This is fresh evidence against the prior retry finding: the new test injects anidleruntime state that the directly wired productionsessionService.get()cannot return for a persisted running chat.Useful? React with 👍 / 👎.