Skip to content

Commit 5bec024

Browse files
authored
feat: improve memory guardrails and live activity tracking (#35)
# RxCode — live-activity-fix * Improved live activity tracking and MCP handling, including resolving duplicate `liveactivity` push issues and optimizing job state coalescing. * Enhanced job activity tracking by adjusting the `isRead` freeze guard and relocating the job-tracking state machine into `JobActivityTracker.swift`. * Expanded memory preferences support to include system prompt memory and modified extraction logic to prioritize preference-related memories. * Implemented comprehensive AppState unit testing coverage with 30 tests and updated service protocols for better mock testing. ## Threads - Logs show duplicate liveactivity pushes. - Commit Message - Update APN for Live Activity Status - Commit and Push - Memory preferences and reminders - Unit Test AppState Coverage Strategy
1 parent 9993230 commit 5bec024

21 files changed

Lines changed: 1588 additions & 168 deletions

Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ public enum IDEToolRegistry {
198198
),
199199
IDETool(
200200
name: "ide__memory_add",
201-
description: "Add a durable memory to RxCode. Use only for stable user preferences, project facts, or decisions that should help future work.",
201+
description: "Add a durable memory to RxCode. Use only when the user explicitly asks to remember something, states a stable preference, or gives a recurring instruction for future/next-time agent runs. Do not save completed work, build results, files changed, available tools, or other transient task details.",
202202
visibility: .alwaysIDEOnly,
203203
inputSchema: .object([
204204
"type": .string("object"),
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import Foundation
2+
3+
/// Latest content the desktop knows for one job in the aggregate Live
4+
/// Activity. Stored in `JobActivityTracker.trackedJobs` in start order.
5+
public struct JobContent: Sendable, Equatable {
6+
public var sessionID: String
7+
public var title: String
8+
public var projectName: String
9+
public var todoDone: Int
10+
public var todoTotal: Int
11+
public var currentStep: String?
12+
/// `true` once the job has finished. It shows the "done" phase but stays
13+
/// in the aggregate list so the activity can report the completed batch.
14+
public var isDone: Bool
15+
/// `true` once the job has finished and the user has viewed it. A read job
16+
/// is frozen — later summaries no longer mutate the tracked entry, so an
17+
/// acknowledged job stops generating Live Activity pushes while staying
18+
/// visible. Deliberately excluded from `signature`: a read-state flip
19+
/// alone must never trigger a push.
20+
public var isRead: Bool
21+
22+
public init(
23+
sessionID: String,
24+
title: String,
25+
projectName: String,
26+
todoDone: Int,
27+
todoTotal: Int,
28+
currentStep: String?,
29+
isDone: Bool,
30+
isRead: Bool
31+
) {
32+
self.sessionID = sessionID
33+
self.title = title
34+
self.projectName = projectName
35+
self.todoDone = todoDone
36+
self.todoTotal = todoTotal
37+
self.currentStep = currentStep
38+
self.isDone = isDone
39+
self.isRead = isRead
40+
}
41+
42+
/// Identifies a distinct rendered state for one job, so an update only
43+
/// pushes on a real change rather than on every session event. Includes
44+
/// `title` so the activity refreshes when the desktop swaps in an
45+
/// AI-summarized title. `isRead` is intentionally excluded.
46+
public var signature: String {
47+
"\(sessionID)|\(isDone ? "done" : "run")|\(title)|\(todoDone)/\(todoTotal)|\(currentStep ?? "")"
48+
}
49+
}
50+
51+
/// Pure, testable state machine behind the aggregate Live Activity. Owns the
52+
/// tracked-job list and the set of streaming sessions, and folds session
53+
/// updates into them. Holds no networking or scheduling — `MobileSyncService`
54+
/// wraps it and performs the throttled APNs pushes on top.
55+
public struct JobActivityTracker: Sendable {
56+
/// Every job tracked by the single aggregate Live Activity: those still
57+
/// running plus recently finished ones, in start order.
58+
public private(set) var trackedJobs: [JobContent] = []
59+
/// Session ids currently streaming — the live job count for the widget.
60+
public private(set) var streamingSessionIDs: Set<String> = []
61+
62+
/// Maximum jobs retained before the oldest finished ones are dropped, so a
63+
/// long-lived device never accumulates an unbounded history.
64+
public static let cap = 6
65+
66+
public init() {}
67+
68+
/// Concatenated per-job signatures — identifies a distinct rendered state.
69+
public var jobsSignature: String {
70+
trackedJobs.map(\.signature).joined(separator: ";")
71+
}
72+
73+
/// `true` once every tracked job has finished.
74+
public var allJobsDone: Bool {
75+
!trackedJobs.isEmpty && trackedJobs.allSatisfy(\.isDone)
76+
}
77+
78+
/// Outcome of folding one session update into the tracker.
79+
public struct IngestResult: Equatable, Sendable {
80+
/// The tracked-job list changed — the Live Activity may need a push.
81+
public var jobsChanged: Bool
82+
/// A finished batch was cleared or a job was re-keyed: the caller must
83+
/// reset its last-pushed signature so the next push is forced out.
84+
public var batchReset: Bool
85+
/// The activity went from every job finished to a job running again.
86+
/// The caller should push immediately, bypassing the update throttle,
87+
/// so the Live Activity wakes up at once instead of a window later.
88+
public var resumedWork: Bool
89+
}
90+
91+
/// Fold one session update into the job set, mirroring the desktop's
92+
/// `updateJobTracking`:
93+
///
94+
/// - A previously-tracked session that re-keys (`previousSessionID`) is
95+
/// moved to the new id, or dropped when no content is supplied.
96+
/// - The streaming set follows `streamingOverride` when given.
97+
/// - A running session is inserted or updated; a finished session updates
98+
/// an existing entry only. When a new job starts while every tracked job
99+
/// is already done, the previous (acknowledged) batch is cleared.
100+
/// - A finished job the user has already read is frozen — later non-
101+
/// streaming updates for it are ignored, but a fresh stream revives it.
102+
@discardableResult
103+
public mutating func ingest(
104+
sessionID: String,
105+
content: JobContent?,
106+
streamingOverride: Bool?,
107+
previousSessionID: String?
108+
) -> IngestResult {
109+
var jobsChanged = false
110+
var batchReset = false
111+
// Captured before folding so a complete → running transition can be
112+
// reported back to the caller for an immediate, un-throttled push.
113+
let wasAllDone = allJobsDone
114+
115+
if let previousSessionID, previousSessionID != sessionID {
116+
streamingSessionIDs.remove(previousSessionID)
117+
if let prevIdx = trackedJobs.firstIndex(where: { $0.sessionID == previousSessionID }) {
118+
if let content {
119+
trackedJobs[prevIdx] = content
120+
} else {
121+
trackedJobs.remove(at: prevIdx)
122+
}
123+
jobsChanged = true
124+
batchReset = true
125+
}
126+
}
127+
128+
if let streamingOverride {
129+
if streamingOverride {
130+
streamingSessionIDs.insert(sessionID)
131+
} else {
132+
streamingSessionIDs.remove(sessionID)
133+
}
134+
}
135+
136+
if let content {
137+
if let idx = trackedJobs.firstIndex(where: { $0.sessionID == content.sessionID }) {
138+
// A finished job the user has already viewed is frozen: keep it
139+
// exactly as last rendered while it stays non-streaming. The
140+
// freeze lifts the instant the session streams again, so a
141+
// follow-up turn brings the job back to "running".
142+
let frozen = trackedJobs[idx].isDone
143+
&& trackedJobs[idx].isRead
144+
&& content.isDone
145+
if !frozen, trackedJobs[idx] != content {
146+
trackedJobs[idx] = content
147+
jobsChanged = true
148+
}
149+
} else if !content.isDone {
150+
// A new running job. If every tracked job is already finished,
151+
// clear that acknowledged batch so the activity starts fresh.
152+
if !trackedJobs.isEmpty, trackedJobs.allSatisfy(\.isDone) {
153+
trackedJobs.removeAll()
154+
batchReset = true
155+
}
156+
trackedJobs.append(content)
157+
jobsChanged = true
158+
}
159+
if prune() { jobsChanged = true }
160+
}
161+
162+
return IngestResult(
163+
jobsChanged: jobsChanged,
164+
batchReset: batchReset,
165+
resumedWork: wasAllDone && !allJobsDone
166+
)
167+
}
168+
169+
/// Cap the tracked-job list, dropping the oldest finished jobs first.
170+
/// Returns `true` when anything was removed.
171+
@discardableResult
172+
private mutating func prune() -> Bool {
173+
var removed = false
174+
while trackedJobs.count > Self.cap {
175+
if let doneIdx = trackedJobs.firstIndex(where: \.isDone) {
176+
trackedJobs.remove(at: doneIdx)
177+
} else {
178+
trackedJobs.removeFirst()
179+
}
180+
removed = true
181+
}
182+
return removed
183+
}
184+
}

0 commit comments

Comments
 (0)