Skip to content

Commit e44c385

Browse files
committed
Fix the review findings from PR #1056
Greptile and CodeRabbit between them found eight things, all verified against the code before changing anything. The renderer-recovery event reported the attempt, not the outcome. It emitted `recovered: true` the moment the budget allowed a reload, so a reload that was attempted and then failed still went out as a recovery. It now reports after `loadURL` settles — true on resolve, false on reject, and false when the budget refuses outright — so the metric describes what the user got rather than what ADE tried. Compaction could be skipped entirely by a payload it could not measure. When `JSON.stringify` throws (a BigInt anywhere in the object), the fallback measured `String(value)` — "[object Object]", 15 bytes, under every cap — so the original unbounded payload was stored and sent untouched, which is the one case a cap exists for. Unmeasurable is now treated as must-compact. A circular reference never reached this path; inline-image redaction breaks the cycle first. Compaction also ran once per subscriber. `sendChatEvent` compacted inside the per-peer loop, so one live event serialized and binary-searched its payload again for every peer watching that session. It is memoized against the envelope now — once per event, no matter how many peers. The shortened-diff matcher was unanchored, on both desktop and iOS, so a real diff whose own changed lines quoted the notice strings — editing the compactor, for instance — was classified as compacted and reported as zero additions and deletions. Both now require the header at the start. iOS measured the suspension gap on the wall clock, the same defect already fixed on the desktop budget: a device whose clock moves backward during a long suspension would report a short or negative gap and go on to trust a socket iOS had already suspended. It uses `systemUptime` now. And a shortened diff no longer draws a `-0` deletion badge through the delete-kind branch, which contradicted the VoiceOver label beside it. One test weakness: `bytes(undefined)` is 0, so upper-bound assertions about `structured` passed just as happily if compaction had deleted the field instead of bounding it. Size claims about fields that must survive now go through a helper that also asserts a lower bound. Regression tests: reload-rejection reporting, the unserializable payload, the anchored matcher on both platforms, and the fenced-content case from the earlier commit.
1 parent 81f45d7 commit e44c385

9 files changed

Lines changed: 135 additions & 36 deletions

File tree

apps/ade-cli/src/services/sync/syncHostService.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5379,9 +5379,24 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
53795379
}
53805380
}
53815381

5382+
/**
5383+
* Compaction serializes the payload and binary-searches it, so doing it per
5384+
* peer meant one live event paid that cost once for every subscriber. The
5385+
* result depends only on the envelope, so it is memoized against the envelope
5386+
* identity and computed once per event no matter how many peers receive it.
5387+
*/
5388+
const compactedSyncEnvelopes = new WeakMap<AgentChatEventEnvelope, AgentChatEventEnvelope>();
5389+
function compactChatEventEnvelopeOnce(event: AgentChatEventEnvelope): AgentChatEventEnvelope {
5390+
const cached = compactedSyncEnvelopes.get(event);
5391+
if (cached) return cached;
5392+
const compacted = compactChatEventEnvelopeForSync(event);
5393+
compactedSyncEnvelopes.set(event, compacted);
5394+
return compacted;
5395+
}
5396+
53825397
function sendChatEvent(peer: PeerState, event: AgentChatEventEnvelope, seq: number): "sent" | "already-sent" | "failed" {
53835398
if (chatEventAlreadySent(peer, event)) return "already-sent";
5384-
const syncEvent = compactChatEventEnvelopeForSync(event);
5399+
const syncEvent = compactChatEventEnvelopeOnce(event);
53855400
const sent = send(peer.ws, "chat_event", { ...syncEvent, seq } satisfies SyncChatEventPayload);
53865401
if (sent) markChatEventSent(peer, event);
53875402
return sent ? "sent" : "failed";

apps/desktop/src/main/main.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -807,16 +807,17 @@ async function createWindow(args: {
807807
// Monotonic on purpose: the budget is a rolling time window, and a wall-clock
808808
// correction mid-crash-storm would either free the budget early or freeze it.
809809
const decision = rendererRecoveryBudget.requestAttempt(details.reason, performance.now());
810-
// A lost renderer is a product-level failure category, so it is reported
811-
// once per occurrence with Electron's own closed reason enum and whether the
812-
// retry budget still allowed a reload. The budget bounds the volume: a
813-
// boot-crash loop stops trying, so it cannot emit forever.
814-
if (isRecoverableRenderProcessGone(details.reason)) {
815-
args.onRendererRecovery?.({
816-
crash_reason: coarseRenderProcessGoneReason(details.reason),
817-
recovered: decision.recover,
818-
});
819-
}
810+
// A lost renderer is a product-level failure category, reported once per
811+
// occurrence with Electron's own closed reason enum. `recovered` describes
812+
// the OUTCOME, not the intent: a reload that was attempted and then failed
813+
// is a window that stayed down, and reporting it as recovered would make the
814+
// metric describe our try rather than the user's result. The budget bounds
815+
// the volume — a boot-crash loop stops trying, so it cannot emit forever.
816+
const crashReason = coarseRenderProcessGoneReason(details.reason);
817+
const reportRecovery = (recovered: boolean): void => {
818+
if (!isRecoverableRenderProcessGone(details.reason)) return;
819+
args.onRendererRecovery?.({ crash_reason: crashReason, recovered });
820+
};
820821
if (!decision.recover) {
821822
if (decision.cause === "budget-exhausted") {
822823
args.logger?.error("window.render_process_recovery_abandoned", {
@@ -825,6 +826,7 @@ async function createWindow(args: {
825826
attempts: decision.attempts,
826827
windowMs: RENDERER_RECOVERY_WINDOW_MS,
827828
});
829+
reportRecovery(false);
828830
}
829831
return;
830832
}
@@ -842,14 +844,18 @@ async function createWindow(args: {
842844
// Load the canonical renderer URL rather than reloading whatever was last
843845
// committed: a crash on the load-failure fallback page would otherwise
844846
// just reload the error page.
845-
win.loadURL(recoveryUrl).catch((error) => {
846-
args.logger?.error("window.render_process_recovery_failed", {
847-
windowId: win.id,
848-
reason: details.reason,
849-
attempt,
850-
err: toErrorMessage(error),
851-
});
852-
});
847+
win.loadURL(recoveryUrl).then(
848+
() => reportRecovery(true),
849+
(error) => {
850+
args.logger?.error("window.render_process_recovery_failed", {
851+
windowId: win.id,
852+
reason: details.reason,
853+
attempt,
854+
err: toErrorMessage(error),
855+
});
856+
reportRecovery(false);
857+
},
858+
);
853859
}, RENDERER_RECOVERY_DELAY_MS);
854860
});
855861

apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,20 @@ describe("summarizeDiffStats", () => {
11161116
expect(summarizeDiffStats(diff)).toEqual({ additions: 0, deletions: 0 });
11171117
});
11181118

1119+
it("does not treat a normal diff containing shortening notices as compacted", () => {
1120+
// Editing the compactor (or this matcher) produces a real diff whose own
1121+
// added lines quote both notice strings. An unanchored search called that
1122+
// change compacted and reported it as zero additions and deletions.
1123+
const diff = [
1124+
"@@ -1,4 +1,6 @@",
1125+
'+ `[ADE] Large ${label} was shortened to keep this chat fast.`,',
1126+
'+ `[ADE] ${omittedBytes} bytes were left out.`,',
1127+
"- const old = true;",
1128+
].join("\n");
1129+
1130+
expect(summarizeDiffStats(diff)).toEqual({ additions: 2, deletions: 1 });
1131+
});
1132+
11191133
/**
11201134
* The notice became user-facing when phones started receiving the same
11211135
* compacted events, so its wording changed. The case above pins the old text

apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,11 @@ export function summarizeDiffStats(diff: string): { additions: number; deletions
652652
// same compaction feeds phones, so it can no longer talk about "stored chat
653653
// history"), but transcripts already on disk carry the old text and must keep
654654
// being recognized as shortened rather than counted as real diff lines.
655-
const shortenedDiff = diff.includes("[ADE] Large file diff was shortened")
655+
// Anchored at the start: the compactor always emits this header as the first
656+
// line. An unanchored search matched a real diff whose own changed lines
657+
// quoted the notice — editing this file, for instance — and reported that
658+
// change as having no additions or deletions.
659+
const shortenedDiff = diff.startsWith("[ADE] Large file diff was shortened")
656660
&& (diff.includes("bytes were left out.") || diff.includes("bytes omitted from stored chat history."));
657661
if (shortenedDiff) {
658662
return { additions: 0, deletions: 0 };

apps/desktop/src/shared/chatEventCompaction.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ function toolResult(overrides: Partial<Extract<AgentChatEvent, { type: "tool_res
1515

1616
const bytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
1717

18+
/**
19+
* `bytes(undefined)` is 0, so an upper-bound assertion alone passes just as
20+
* happily when a field was deleted as when it was bounded. Every size claim
21+
* about a field that must SURVIVE compaction goes through here.
22+
*/
23+
function expectBoundedNotDropped(value: unknown, maxBytes: number) {
24+
expect(value).toBeDefined();
25+
expect(bytes(value)).toBeGreaterThan(0);
26+
expect(bytes(value)).toBeLessThan(maxBytes);
27+
}
28+
1829
describe("chat event compaction", () => {
1930
/**
2031
* The defect this module exists to prevent: `structured` was added to
@@ -27,8 +38,8 @@ describe("chat event compaction", () => {
2738

2839
const stored = compactChatEventForStorage(event) as Extract<AgentChatEvent, { type: "tool_result" }>;
2940

30-
expect(bytes(stored.structured)).toBeLessThan(bytes(huge) / 10);
31-
expect(bytes(stored.structured)).toBeLessThan(32 * 1024);
41+
expectBoundedNotDropped(stored.structured, Math.floor(bytes(huge) / 10));
42+
expectBoundedNotDropped(stored.structured, 32 * 1024);
3243
});
3344

3445
it("leaves a small structured payload untouched", () => {
@@ -157,6 +168,26 @@ describe("chat event compaction", () => {
157168
expect(bytes(stored.result)).toBeLessThan(40 * 1024);
158169
});
159170

171+
it("bounds a payload that cannot be serialized at all", () => {
172+
// A BigInt anywhere in the payload makes JSON.stringify throw, and the
173+
// fallback measured "[object Object]" — 15 bytes, under every cap — so the
174+
// original unbounded object was stored and sent untouched. (A circular
175+
// reference does not reach here: inline-image redaction breaks the cycle
176+
// first, after which the payload measures normally.)
177+
const unserializable: Record<string, unknown> = {
178+
rows: Array.from({ length: 5_000 }, (_, i) => `row ${i}`),
179+
cursor: BigInt(42),
180+
};
181+
182+
const stored = compactChatEventForStorage(
183+
toolResult({ result: unserializable }),
184+
) as Extract<AgentChatEvent, { type: "tool_result" }>;
185+
186+
expect(stored.result).not.toBe(unserializable);
187+
expect(bytes(stored.result)).toBeLessThan(4 * 1024);
188+
expect(JSON.stringify(stored.result)).toContain("[ADE]");
189+
});
190+
160191
it("is idempotent on the text branches too", () => {
161192
// These carry no wrapper to recognize; they are stable only because the
162193
// compacted output lands under the cap. Shrinking that headroom would
@@ -205,7 +236,7 @@ describe("chat event compaction", () => {
205236

206237
expect(stored.resultOriginalBytes).toBe(5_000_000);
207238
expect(stored.resultOmittedBytes).toBe(4_900_000);
208-
expect(bytes(stored.structured)).toBeLessThan(32 * 1024);
239+
expectBoundedNotDropped(stored.structured, 32 * 1024);
209240
});
210241

211242
it("returns the same object when there is nothing to compact", () => {

apps/desktop/src/shared/chatEventCompaction.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,20 @@ const isCompactedPayloadWrapper = (value: unknown): boolean => {
200200
&& typeof candidate.omittedBytes === "number";
201201
};
202202

203-
const stringifyPayloadForCompaction = (value: unknown): { text: string; structured: boolean } => {
204-
if (typeof value === "string") return { text: value, structured: false };
203+
const stringifyPayloadForCompaction = (
204+
value: unknown,
205+
): { text: string; structured: boolean; serializable: boolean } => {
206+
if (typeof value === "string") return { text: value, structured: false, serializable: true };
205207
try {
206208
const json = JSON.stringify(value, null, 2);
207-
if (typeof json === "string") return { text: json, structured: true };
208-
return { text: String(value), structured: false };
209+
if (typeof json === "string") return { text: json, structured: true, serializable: true };
210+
return { text: String(value), structured: false, serializable: true };
209211
} catch {
210-
return { text: String(value), structured: false };
212+
// A circular reference or a BigInt anywhere in the payload lands here, and
213+
// `String(value)` is "[object Object]" — 15 bytes, under every cap. Reporting
214+
// that as the size let the ORIGINAL unbounded object through untouched,
215+
// which is the one case the cap exists for.
216+
return { text: String(value), structured: false, serializable: false };
211217
}
212218
};
213219

@@ -232,6 +238,16 @@ const compactStoredUnknownPayload = (
232238
return null;
233239
}
234240
const serialized = stringifyPayloadForCompaction(value);
241+
if (!serialized.serializable) {
242+
// Unmeasurable is not "small". Substitute a bounded placeholder rather than
243+
// storing and transmitting a payload no cap could see.
244+
const summary = `[ADE] Large ${label} could not be measured and was left out.`;
245+
return {
246+
value: { summary, originalBytes: 0, omittedBytes: 0, preview: serialized.text.slice(0, 256) },
247+
originalBytes: 0,
248+
omittedBytes: 0,
249+
};
250+
}
235251
const compacted = compactStoredTextPayload(label, serialized.text, maxBytes);
236252
if (!compacted) return null;
237253
if (!serialized.structured) {

apps/ios/ADE/Services/SyncService.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3781,8 +3781,12 @@ final class SyncService: ObservableObject {
37813781
private var pendingRemoteProfileDbVersionBySite: [String: Int] = [:]
37823782
private let discoveryBrowser = SyncBonjourBrowser()
37833783
private var reconnectState = SyncReconnectState()
3784-
/// When the app last went to the background, used to classify the resume.
3785-
private var backgroundedAt: Date?
3784+
/// Uptime when the app last went to the background, used to classify the
3785+
/// resume. Deliberately monotonic (`systemUptime`), not wall clock: a device
3786+
/// whose clock moves backward during a long suspension would otherwise report
3787+
/// a short or negative gap and go on to trust a socket iOS had already
3788+
/// suspended — the exact failure this classifier exists to prevent.
3789+
private var backgroundedAtUptime: TimeInterval?
37863790
private var envelopeChunkAssembler = SyncEnvelopeChunkAssembler()
37873791
private var envelopeChunkExpiryTask: Task<Void, Never>?
37883792
private var transportProbeTask: Task<Void, Never>?
@@ -7786,15 +7790,15 @@ final class SyncService: ObservableObject {
77867790
/// long background to `.refreshOnly` and trust a socket iOS may already have
77877791
/// suspended. The cheap error is the safe one.
77887792
func handleBackgroundTransition() {
7789-
backgroundedAt = Date()
7793+
backgroundedAtUptime = ProcessInfo.processInfo.systemUptime
77907794
}
77917795

77927796
func handleForegroundTransition() async {
77937797
refreshPhoneTailnetInterfaceState()
77947798
let resumeAction = syncForegroundResumeAction(
7795-
backgroundGapSeconds: backgroundedAt.map { Date().timeIntervalSince($0) }
7799+
backgroundGapSeconds: backgroundedAtUptime.map { ProcessInfo.processInfo.systemUptime - $0 }
77967800
)
7797-
backgroundedAt = nil
7801+
backgroundedAtUptime = nil
77987802
// Coming to the foreground is new information: the user is here, and the
77997803
// network may be a different one entirely. Reset the attempt ladder even
78007804
// from the terminal unreachable state, which otherwise costs the user a

apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,13 @@ struct WorkFileChangeCardView: View {
999999
/// counts are zero. VoiceOver would otherwise read the same zeros as a fact
10001000
/// about the change ("0 additions, 0 deletions") instead of the absence of a
10011001
/// measurement, so it gets the reason instead.
1002+
/// A shortened diff has no trustworthy counts, so neither badge is drawn —
1003+
/// the delete-kind branch would otherwise still render `-0` and contradict the
1004+
/// VoiceOver label right beside it.
1005+
private var showsChangeCounts: Bool {
1006+
!workDiffWasShortened(card.diff)
1007+
}
1008+
10021009
private var changeCountDescription: String {
10031010
workDiffWasShortened(card.diff)
10041011
? "Change counts unavailable, diff was shortened"
@@ -1038,12 +1045,12 @@ struct WorkFileChangeCardView: View {
10381045

10391046
Spacer(minLength: 6)
10401047

1041-
if diffStats.additions > 0 {
1048+
if showsChangeCounts, diffStats.additions > 0 {
10421049
Text("+\(diffStats.additions)")
10431050
.font(.caption.monospaced())
10441051
.foregroundStyle(ADEColor.success)
10451052
}
1046-
if diffStats.deletions > 0 || card.kind.lowercased() == "delete" {
1053+
if showsChangeCounts, diffStats.deletions > 0 || card.kind.lowercased() == "delete" {
10471054
Text("-\(diffStats.deletions)")
10481055
.font(.caption.monospaced())
10491056
.foregroundStyle(ADEColor.danger)

apps/ios/ADE/Views/Work/WorkEventMapping.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -927,7 +927,9 @@ func nonEmpty(_ value: String) -> String? {
927927
/// chat history" to "to keep this chat fast" — but transcripts already on disk
928928
/// carry the old wording and must keep being recognized.
929929
func workDiffWasShortened(_ diff: String) -> Bool {
930-
guard diff.contains("[ADE] Large file diff was shortened") else { return false }
930+
// Anchored at the start, mirroring desktop: an unanchored search matched a
931+
// real diff whose own changed lines quoted the notice.
932+
guard diff.hasPrefix("[ADE] Large file diff was shortened") else { return false }
931933
return diff.contains("bytes were left out.")
932934
|| diff.contains("bytes omitted from stored chat history.")
933935
}

0 commit comments

Comments
 (0)