Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/superpowers/plans/2026-09-05-subagent-refresh-coalescing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Subagent refresh coalescing

Status: implemented; PR/CI review pending. Issue #802. Base: origin/main at 5d641845.

## Outcome and boundaries

Each child transcript byte range is folded once despite parent-record bursts,
timers, and manual refreshes. A trigger during I/O schedules a trailing pass;
late appends and parent completion are preserved. Stopping a tracker prevents
in-flight I/O from repopulating state or emitting. No provider process, renderer,
worktree reconciliation, or external control changes belong in this PR.

## Implementation

1. Add a small tracker-local serialized refresh owner, coalescing pending
requests into one trailing pass and containing background I/O failures.
2. Route Claude and Codex refresh triggers through it. Check stopped state
after awaited reads before committing offsets, metadata, or accumulators.
3. Keep transcript UTF-8/partial-line and parent completion semantics intact.
4. Port the audit burst reproduction into deterministic deferred-read tests for
both trackers, including appended bytes, completion during I/O, transient
errors, and stop during I/O. Keep fixtures synthetic and private-data-free.

## Validation and review

Run focused subagent tests, testing contract and typecheck. Inspect before/after
behavior with the same synthetic burst (one child record, 50 parent triggers);
report duplicate fold and read counts, not production CPU/FPS claims. Review the
final diff, open a complete Conventional PR with Fixes #802, synchronize the
issue, and address applicable CI/review feedback. Never merge without explicit
user confirmation.

## Following work

Issue #803 will use a separate branch/PR based on this branch because both touch
Codex refresh ownership. Remote #804 starts independently from main; remote #805
will explicitly depend on that resynchronization contract. Complete all four.

## Implementation evidence

- Shared CoalescedRefresh now owns each tracker’s poll loop; post-await stop
guards prevent offset/accumulator revival. No discovery policy changes here.
- 34 focused tests pass (including 10 new lifecycle/concurrency cases). The
baseline fails both provider burst cases: up to 52 overlapping range reads
versus one after coalescing. A late append causes exactly two sequential reads.
- Typecheck and testing contract passed; final incremental typecheck and CI
review complete the validation. Evidence is synthetic, not production speedup.

Refreshed base: rebased onto main f7507980 after toolkit #812 and MCP repair
#818 merged. Their files do not overlap this implementation; rerun checks on
the refreshed PR head before requesting merge approval.
49 changes: 49 additions & 0 deletions src/main/subagents/CoalescedRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** One byte-offset owner per tracker, even when parent events arrive in bursts.
*
* A boolean `busy` guard that simply drops requests loses appends/completion
* arriving during I/O. Queueing every request instead re-scans once per parent
* record. The pending bit means exactly one trailing pass for any such burst;
* requests during that pass can ask for another pass without concurrent reads.
*/
export class CoalescedRefresh {
private pending = false
private stopped = false
private inflight: Promise<void> | null = null

constructor(private readonly poll: () => Promise<void>) {}

request(): Promise<void> {
if (this.stopped) return Promise.resolve()
this.pending = true
if (!this.inflight) {
// Defer entry until inflight is assigned, including a synchronous throw.
// Clear ownership INSIDE this continuation: clearing it in .finally()
// leaves a microtask gap in which a request can join an already finished
// drain and strand its pending bit until the next periodic timer.
this.inflight = Promise.resolve().then(async () => {
try {
while (this.pending && !this.stopped) {
this.pending = false
try {
await this.poll()
} catch {
// Polling observes externally created/rotated files. Failure is
// retryable on the next requested pass, never an unhandled
// rejection from a fire-and-forget parent event or timer.
}
}
} finally {
this.inflight = null
}
})
}
return this.inflight
}

stop(): void {
this.stopped = true
this.pending = false
// The poller still owns its open I/O. Its post-await stop guards prevent
// writes/emissions; retaining the promise lets existing callers drain it.
}
}
21 changes: 16 additions & 5 deletions src/main/subagents/SubAgentWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from './subagentState.js'
import type { SubAgentAccumulator, SubAgentMeta } from './subagentState.js'
import { readRange } from './shared.js'
import { CoalescedRefresh } from './CoalescedRefresh.js'

// One poller per session, watching <sessionDir>/subagents/.
//
Expand Down Expand Up @@ -76,6 +77,7 @@ export class SubAgentWatcher {
private prunedAgents = new Set<string>()
private dirty = false
private stopped = false
private readonly refreshLoop = new CoalescedRefresh(() => this.tick())

constructor(
private readonly subagentsDir: string,
Expand All @@ -84,15 +86,17 @@ export class SubAgentWatcher {
) {}

start(): void {
if (this.stopped || this.timer) return
// Kick once immediately so an already-populated dir surfaces fast, then
// poll. The first tick also covers the common "dir created moments later"
// case — readdir simply throws and we retry next tick.
void this.tick()
this.timer = setInterval(() => void this.tick(), POLL_MS)
void this.refreshLoop.request()
this.timer = setInterval(() => void this.refreshLoop.request(), POLL_MS)
}

stop(): void {
this.stopped = true
this.refreshLoop.stop()
if (this.timer) clearInterval(this.timer)
this.timer = null
this.offsets.clear()
Expand All @@ -106,9 +110,10 @@ export class SubAgentWatcher {

/** Force a re-emit (e.g. the parent transcript just produced a tool_result
* that flips a subagent running→done). */
refresh(): void {
refresh(): Promise<void> {
if (this.stopped) return Promise.resolve()
this.dirty = true
void this.tick()
return this.refreshLoop.request()
}

private async tick(): Promise<void> {
Expand All @@ -120,7 +125,7 @@ export class SubAgentWatcher {
// tick. Any other transient FS error is also safe to retry.
return
}
if (this.dirty) {
if (!this.stopped && this.dirty) {
this.dirty = false
this.emit()
}
Expand All @@ -129,12 +134,14 @@ export class SubAgentWatcher {
private async rescan(): Promise<void> {
const files = await readdir(this.subagentsDir)
for (const f of files) {
if (this.stopped) return
if (f.endsWith('.meta.json')) {
const agentId = f.slice('agent-'.length, -'.meta.json'.length)
if (this.prunedAgents.has(agentId)) continue // finding-16: reclaimed
if (this.metaByAgent.has(agentId)) continue // meta is written once
try {
const raw = await readFile(join(this.subagentsDir, f), 'utf8')
if (this.stopped) return
this.metaByAgent.set(agentId, JSON.parse(raw) as SubAgentMeta)
this.dirty = true
} catch {
Expand All @@ -153,6 +160,7 @@ export class SubAgentWatcher {

private async readAppended(agentId: string, path: string): Promise<void> {
const { size } = await stat(path)
if (this.stopped) return
const from = this.offsets.get(agentId) ?? 0
if (size <= from) return

Expand All @@ -166,6 +174,9 @@ export class SubAgentWatcher {
// Reading only `[from, size)` keeps the watcher proportional to new bytes,
// which is the actual invariant future code should preserve.
const appended = await readRange(path, from, size)
// stop() clears all fold state while this read may still be in flight.
// Never repopulate an offset/accumulator or emit for a dead session.
if (this.stopped) return
const text = (this.partialByAgent.get(agentId) ?? '') + appended.text
const lastNl = text.lastIndexOf('\n')
if (lastNl < 0) {
Expand Down
14 changes: 13 additions & 1 deletion src/main/subagents/codexSubagentState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { basename, dirname, join } from 'node:path'
import { readdir, stat } from 'node:fs/promises'
import type { JsonlEntry, SubAgentState, SubAgentToolCall } from '@preload/api/types.js'
import { asRecord } from '@shared/lib/asRecord.js'
import { CoalescedRefresh } from './CoalescedRefresh.js'
import {
capToolCalls,
headlineFromInput,
Expand Down Expand Up @@ -559,10 +560,12 @@ export class CodexSubAgentTracker {
private parentFile: string | null = null
private dirty = false
private stopped = false
private readonly refreshLoop = new CoalescedRefresh(() => this.poll())

constructor(private readonly onChange: (subAgents: Record<string, SubAgentState>) => void) {}

observeParentEntry(entry: JsonlEntry, file: string): void {
if (this.stopped) return
this.parentFile = file
const spawn = extractCodexSpawnCall(entry)
if (spawn) {
Expand Down Expand Up @@ -625,6 +628,7 @@ export class CodexSubAgentTracker {

stop(): void {
this.stopped = true
this.refreshLoop.stop()
if (this.timer) clearInterval(this.timer)
this.timer = null
// Mirror SubAgentWatcher.stop() (PR #300): clearing the timer alone left
Expand All @@ -645,7 +649,11 @@ export class CodexSubAgentTracker {
this.childAccByAgentId.clear()
}

async refresh(): Promise<void> {
refresh(): Promise<void> {
return this.refreshLoop.request()
}

private async poll(): Promise<void> {
if (this.stopped || !this.parentFile) return
await this.readKnownChildren()
// POST-AWAIT stop guard (PR #317, race fix). The pre-await check above only
Expand Down Expand Up @@ -693,6 +701,7 @@ export class CodexSubAgentTracker {

private async readAppendedChild(agentId: string, path: string): Promise<boolean> {
const { size } = await stat(path)
if (this.stopped) return false
let from = this.childOffsetByAgentId.get(agentId) ?? 0
if (size < from) {
// Rollouts are append-only in normal Codex operation, but editors/tests can
Expand All @@ -706,6 +715,9 @@ export class CodexSubAgentTracker {
if (size <= from) return false

const appended = await readRange(path, from, size)
// readKnownChildren's outer guard is too late: these maps must not be
// repopulated after stop(), even if an open range read finishes afterwards.
if (this.stopped) return false
const text = (this.childPartialByAgentId.get(agentId) ?? '') + appended.text
const lastNl = text.lastIndexOf('\n')
this.childOffsetByAgentId.set(agentId, appended.nextOffset)
Expand Down
Loading