fix(assistant): thread-safety in agent run loop and job cancellation#103
fix(assistant): thread-safety in agent run loop and job cancellation#103faiqq7 wants to merge 1 commit into
Conversation
mAgentRunGeneration was a non-volatile int written on the UI thread (runAgent, cancelAgentRun, live-voice starts) and read from the agent background thread inside isCancelled(). Without synchronization the agent thread has no visibility guarantee for writes from the UI thread, so a cancellation could go unobserved. Converted to AtomicInteger and updated every read/write site to use incrementAndGet()/get(). OpenPhoneAgentJobScheduler.isCancelled() hardcoded false, so a background agent_turn job could never be stopped once its thread started. Added a per-job AtomicBoolean cancel flag (keyed by job id in a ConcurrentHashMap) and a new cancelJob(String) entry point that sets it; the flag is cleared in the job's finally block. I also reviewed the pending-confirmation fields (mPendingActionId, mPendingToolName, mPendingToolArguments, mPendingOneShotMessage) called out in the issue. Every write site I found is either inside a runOnUiThread block or a method only invoked from the UI thread, and the one place a background Thread reads them (confirmPending) does so via local finals captured before Thread.start(), which is safe under Java's happens-before guarantee. I did not find an actual race on these fields, so I left them unchanged rather than add synchronization for a scenario I could not reproduce or confirm — flagging this in the PR in case I've missed a call path. Fixes secondly-com#70
|
Reviewed against latest Blocking findings:
Validation run in an isolated worktree after merging this PR onto latest main:
The |
adamcohenhillel
left a comment
There was a problem hiding this comment.
Thanks for this — the AssistantActivityBackend half is exactly right (all 13 mAgentRunGeneration sites converted, and I agree with your analysis that the pending-confirmation fields are UI-thread-confined; the final capture before Thread.start() is safe under the happens-before rule).
One blocking issue: the scheduler half doesn't compile. AgentJobRecord.id is a long (AgentJobRecord.java:4), but sCancelFlags is keyed on String:
OpenPhoneAgentJobScheduler.java:126: error: incompatible types: long cannot be converted to String
AtomicBoolean cancelFlag = sCancelFlags.computeIfAbsent(job.id,
Fix is small — key the map on Long:
private static final ConcurrentHashMap<Long, AtomicBoolean> sCancelFlags =
new ConcurrentHashMap<>();
public static void cancelJob(long jobId) {
AtomicBoolean flag = sCancelFlags.get(jobId);
if (flag != null) {
flag.set(true);
}
}(and computeIfAbsent(job.id, ...) / sCancelFlags.remove(job.id) then work as-is).
I know you couldn't run the Java check without the SDK — for future reference ./scripts/check.sh without OPENPHONE_SKIP_JAVA_CHECK=1 runs a plain javac compile check, which is what caught this. Happy to merge once it compiles.
What
mAgentRunGenerationfrom a plaininttoAtomicIntegerinAssistantActivityBackend.javaand updates all 12 read/write sites to useincrementAndGet()/get().OpenPhoneAgentJobScheduler.isCancelled()to a real per-job cancellation flag instead of hardcodedfalse.Why
mAgentRunGenerationis written on the UI thread (runAgent,cancelAgentRun, the two live-voice start paths) and read from the agent background thread insideisCancelled(). As a non-volatile field there's no visibility guarantee across threads, so a cancellation from the UI thread could go unobserved by the running agent thread.OpenPhoneAgentJobScheduler.isCancelled()always returnedfalse, so a backgroundagent_turnjob could never be stopped once its worker thread started.Change
mAgentRunGeneration→AtomicInteger, all call sites updated (AssistantActivityBackend.java).OpenPhoneAgentJobScheduler: addedsCancelFlags(ConcurrentHashMap<String, AtomicBoolean>) and acancelJob(String jobId)entry point.runJob()registers a flag for the job at start,isCancelled()reads it, and it's removed in thefinallyblock. This is additive — no existing callers ofcheckNow/scheduleNextneeded changes.What I did not change, and why
The issue also calls out
mPendingActionId/ pending-confirmation fields as needing a lock orAtomicReferencesnapshot. I traced every read and write site: they're all either insiderunOnUiThreadblocks, in methods only reachable from the UI thread, or (inconfirmPending) captured intofinallocals before a backgroundThreadis started — which is safe under Java's happens-before guarantee forThread.start(). I couldn't find an actual race on these fields, so I left them as-is rather than add synchronization for a scenario I couldn't confirm. Flagging this explicitly in case there's a call path I missed — happy to add a lock if a maintainer can point to one.Validation
OPENPHONE_SKIP_JAVA_CHECK=1 ./scripts/check.shpasses.Fixes #70