feat: enhance agent runner - #404
Conversation
- convlock.go: gofmt field alignment in refCountedMutex - prompt.go: goimports local-prefix grouping (github.com/google/uuid must come before the github.com/Paca-AI/agent-runner group per .golangci.yml's local-prefixes setting) - use-conversation-event-window.ts: biome import sort order Verified: golangci-lint run (0 issues), bun run lint (0 issues), go build/vet/test -race all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Full initial review of all 23 files in commit bca8214 (agent-runner, apps/mcp, apps/web): read the entire diff front to back, traced every changed seam against the surrounding code, verified the third-party Goose provider claims against block/goose source, and ran the touched Go packages' unit tests (all pass under -race).
- Per-conversation trigger serialization — a refcounted per-
conversation_idlock in the Valkey consumer guarantees two triggers for the same conversation never runHandlerconcurrently, closing the in-memoryevent_indexincrement and in-flight-registry races at their root; lock is acquired before the semaphore slot so queued triggers don't exhaust it. - Token-owned in-flight registrations —
Register/Unregisternow hand back an ownership token so a stale deferredUnregistercan't clear a newer turn's entry (pause/resume turn overlap case);TeardownPausedChatSandboxre-checksIsRegisteredbefore popping so the idle reaper or a stop can't tear a resuming turn's sandbox out from under it. - ACP bridge reconnect leak fix —
Registernow cancels, closes, and waits on a superseded same-process connection, so its forwarder/eviction-watcher goroutines and Redis Pub/Sub subscription actually exit instead of running forever against an orphaned connection. - Bundled-skills failure surfaced as terminal — the conversation is marked
runningbefore skill load, so a transientservices/apioutage duringBundledSkills.Loadlands as a visiblefailedstatus + ack instead of an unacked message that is never actually redelivered (no XCLAIM in this consumer). - Goose provider ID aliases —
gemini→googleanddeepseek→custom_deepseekvia an explicit alias table; verified againstblock/goosesource (GOOGLE_PROVIDER_NAME,deepseek.json). Both previously failed to initialize silently for every conversation on those providers. - Global-chat and automation prompt context — nil-project triggers get a global-agent context block instead of a bogus nil-UUID framing; automation-fired triggers get the "no human watching" note;
PACA_ACTOR_USER_IDis now sourced fromActorUserID(wasActorMemberID, a member id mislabeled as a user id) so global-chat identity reaches apps/mcp. - Turn-scoped diff baseline — a per-path baseline lets a second edit of the same file in one turn diff incrementally against the first edit's result rather than always against HEAD.
- Sandbox image-confirmation cache —
ensureImageskipsImageList/ImagePullentirely for an already-confirmed pinned ref on subsequent sandbox starts. - Realtime tail-cache pruning —
useConversationEventWindowprunes the tail buffer down to events past the fetched window once a real fetch covers them, bounding a previously unbounded per-tab growth. clone_repositorydelete guard —assertSafeDeleteTargetrefuses the recursivermagainst a hand-picked set of top-level system directories regardless of what path the agent supplies, closing a prompt-injection crater the Python port it replaces never had.- Docs — new
services/agent-runner/README.mdand expanded architecture doc, including the already-wiredPACA_MCP_DEV_SOURCE_DIR.
Every load-bearing claim in the doc comments checks out against the code and — where it matters (provider IDs) — against upstream Goose source. The concurrency fixes are subtle and each carries a regression test that genuinely fails without the fix. Nothing to block or change.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Incremental delta since the prior review (commit bca8214): the new commit 479e0ca ("fix: resolve CI lint failures") is purely mechanical formatting — gofmt field alignment in convlock.go, goimports grouping in prompt.go, and a biome import-order fix in use-conversation-event-window.ts. No behavioral surface.
The original substantive review stands; nothing in this commit changes any of the previously-reviewed behavior.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ety in agent runner
There was a problem hiding this comment.
ℹ️ No critical issues — the new commit is correct and well-tested. One minor consolidation suggestion inline.
Reviewed changes — Incremental delta since the prior review (commit 479e0ca): commit ab390b0 ("feat: implement per-key locking mechanism and enhance concurrency safety in agent runner").
- Per-agent
registerLocksin the ACP bridge —Registerswapped the single process-wideregisterMufor aconvlockLockskeyed peragent_id, so one agent's potentially unbounded eviction wait (<-prev.done) can no longer stall every other agent'sRegisteron this replica. Correct: presence, connection-map, and broadcast state are all strictly per-agent. Verifying testTestRegister_DifferentAgentsDoNotBlockOnEachOthersEvictiongenuinely blocks one agent's connector and asserts another'sRegistercompletes. resumeLockcloses the Handle-vs-Teardown check-then-act race —Handle's registrar-of-flight +ChatSandboxes.Getnow runs atomically, perconversation_id, withTeardownPausedChatSandbox'sIsRegistered-check +Pop, so a stop/reaper can't tear down the sandbox a resuming turn just read. Held only across the two fast in-memory ops, never for the whole turn. The race test uses realregistry.Conversations+chatsandbox.Registryand would fail without the lock.- New generic
internal/convlockpackage — per-key refcounted mutex used by both the handler and the bridge (see inline note). clone_repositoryhardening —/home/goose(the container user's home, parent of the default clone dir) added to the forbidden-delete set, with traversal test cases (/home/goose/../→/home).
Verified: go build ./..., go vet, and go test -race all clean on the touched packages (convlock, handler, acpbridge, messaging). Nothing to block.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Incremental delta since the prior review (commit ab390b0): commit d641507 ("refactor: replace conversationLocks with convlock for improved trigger serialization") consolidates the duplicated per-key refcounted mutex.
- Deduplicated the per-key lock primitive — deleted
internal/messaging/convlock.go(conversationLocks) and switched themessaging.Consumerto the sharedconvlock.Lockspackage (convlock.New()/Lock(trigger.ConversationID)), with doc comments updated to match. This directly implements the consolidation suggestion from the prior review. - Behavior-preserving —
convlock.Locksis byte-for-byte the same refcounted mutexconversationLockswas; the per-conversation_idserialization (lock acquired before the semaphore slot) and the regression test are unchanged. Verified the lock is still held only across the semaphore-acquire and handler call, never the whole run loop.
Verified locally: no lingering references to the old symbols, go build ./... and go vet ./... clean, and go test -race ./internal/messaging/... ./internal/convlock/... pass, including TestConsumer_SerializesTriggersForTheSameConversation. Consolidation done as suggested; nothing to block.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues — the new preset instructions are well-formed and their
## Skill: …anchors genuinely resolve below the system prompt at runtime; two minor prompt-model notes inline.
Reviewed changes — Incremental delta since the prior Pullfrog review (commit d641507): the new commit 1eb96dee (single file, apps/web/src/lib/agent-api.ts). Each bundled LLM preset's defaultSystemPrompt now appends: "when you're assigned a task with nothing else said, skip skill-routing analysis and go straight to the <preset> skill (## Skill: <name> below)" — software-engineer → paca-do, code-reviewer & qa-engineer → paca-test, planner → paca-sprint, business-analyst → paca-clarify.
- Prompt text, no code changes — the create-agent dialog stores these strings as the agent's
system_prompt, whichagent-runner'sbuildInitialMessageemits first, followed by every enabled bundled skill as## Skill: <name>sections. Confirmed againstservices/agent-runner/internal/executor/prompt.go:83-96andservices/api/internal/platform/bundledskills— all four referenced skills exist as agent-flavor entries (noneCLIOnly), so nothing dangles. - Consistent injection for every LLM conversation — the handler always prepends the bundled agent-flavor set to
cfg.Skills(handler.go:135-152), so the referenced sections are always present below the system prompt regardless of per-agent skill customization.
Two optional refinements inline (both non-blocking observations, not defects).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| defaultLLMModel: "claude-sonnet-4-6", | ||
| defaultSystemPrompt: | ||
| "You are an expert software engineer. You implement features and fix bugs by writing clean, maintainable code and following best practices.", | ||
| "You are an expert software engineer. You implement features and fix bugs by writing clean, maintainable code and following best practices.\n\nWhen you're assigned a task with nothing else said, skip skill-routing analysis and go straight to the `paca-do` skill (`## Skill: paca-do` below) to execute it end-to-end — don't narrate the choice, just get to work.", |
There was a problem hiding this comment.
The hard-wired "skip skill-routing analysis" directive sits directly above the always-injected bundled paca skill, whose Step 0 is written as a hard requirement ("You MUST locate the matching ## Skill: <name> section below and follow it", based on task status). For a task whose status the bundle's routing table would send to a different skill than the preset's fixed one (e.g. an in-review task that Step 0.5 maps to paca-test, but the software-engineer preset forces paca-do), the two prompt blocks now give the model explicitly conflicting orders. Usually harmless since the preset text is more specific, but wording the override explicitly ("this overrides the paca skill's routing step") would remove the ambiguity.
| defaultLLMModel: "claude-sonnet-4-6", | ||
| defaultSystemPrompt: | ||
| "You are an expert project planner. You break down goals into well-defined tasks using `create_task`. For each task, set an appropriate task type (use `list_task_types` to see available types), a clear title, description, and acceptance criteria. Group related tasks under Epics or parent tasks where appropriate. Use `list_task_statuses` to understand the project's workflow.", | ||
| "You are an expert project planner. You break down goals into well-defined tasks using `create_task`. For each task, set an appropriate task type (use `list_task_types` to see available types), a clear title, description, and acceptance criteria. Group related tasks under Epics or parent tasks where appropriate. Use `list_task_statuses` to understand the project's workflow.\n\nWhen you're assigned a task with nothing else said, skip skill-routing analysis and go straight to the `paca-sprint` skill (`## Skill: paca-sprint` below) — don't narrate the choice, just get to work.", |
There was a problem hiding this comment.
The planner preset's own defaultSystemPrompt is about task breakdown, create_task, types, and epics — persona-wise a fitter for the paca-breakdown / paca-epic sections — but the assigned-task path is hard-wired to paca-sprint (backlog planning). The bundled routing table only maps "to do / ready" tasks to paca-sprint; a differently-stated task would follow paca-do or paca-clarify. Since a planner is a persona, not a workflow, verify this mapping (or at least well-documented) so the preset routes towards the status it describes.

Summary
Fixes 15 findings from a full code review of the
services/agent-runnermigration (services/ai-agentPython → Go/Goose), covering correctness bugs, concurrency races, a path-safety gap, and one efficiency issue — plus new documentation for the service. Every fix ships with a regression test; the concurrency fixes are additionally verified undergo test -race, and each of those tests was confirmed to actually catch its bug (temporarily reverted the fix locally, watched the test fail, restored it).Correctness
buildMCPServerssentPACA_ACTOR_USER_IDfromtrigger.ActorMemberIDinstead oftrigger.ActorUserID— the former is set on every project-scoped trigger and gets rejected byservices/api'sverifyAgentIdentity(which only accepts an actor-user-id claim for a global-scope agent), breakingget_task,clone_repository, and every other MCP tool call during normal project chat.buildInitialMessageunconditionally renderedYou are working inside project \00000000-0000-0000-0000-000000000000`` for global-chat conversations instead of the intended "you are a global agent" framing.resolveProviderEnvpassed Paca'sllm_providervalue straight through asGOOSE_PROVIDER, but Goose registers Gemini as"google"and DeepSeek as"custom_deepseek"— verified directly againstblock/goose's source (a public docs page for Goose turned out to be wrong about this).coherehas no Goose provider at all; left mapped (with a comment explaining why) so it fails with a clear "unknown provider" error instead of silently misrouting through the OpenAI fallback.BundledSkills.Loadcould fail before the conversation was ever markedrunning, and this service's Valkey consumer has no redelivery mechanism, so the conversation just sat there. Reordered sorunningis written first; a load failure now marks the conversationfailedwith the underlying error.git HEAD, so the second edit's diff card showed both edits combined. Now tracks a per-turn baseline per file.clone_repositoryrecursively force-deleted an agent-suppliedtargetDirwith no validation — a task that got the agent to pass/,/home, or/etcwould wipe it out. Now refuses a short list of protected top-level directories.Concurrency safety
Root cause: nothing prevented two triggers for the same
conversation_idfrom runningHandle()concurrently, which enabled three related races:event_indexis allocated once per turn and incremented in-memory afterward; two concurrent turns could allocate the same index, andInsertEvent'sON CONFLICT DO NOTHINGsilently dropped the loser's events.registry.Conversations.Register/Unregisterhad no ownership check, so a paused turn's deferredUnregistercould delete a newer turn's live cancel entry.Fixed at the root:
internal/messaging.Consumernow serializes trigger handling perconversation_id(different conversations still run concurrently). Layered with defense-in-depth:Registernow returns an ownership tokenUnregistermust match,Handle()registers in-flight before reading the paused sandbox, andTeardownPausedChatSandboxre-checksInFlight.IsRegisteredbefore popping.Also fixed a real goroutine/Redis-subscription leak in the ACP bridge:
acpbridge.Registry.Registeroverwrote the connections map with no reference to the previous entry, so a same-process reconnect left the old connection's forwarder goroutine running forever.Efficiency
sandbox.Manager.ensureImagecalleddocker.ImageList(enumerating every image on the host) on every single conversation start, even though the image is pinned for the process's lifetime. Now caches "confirmed present" after the first check.Documentation
services/agent-runner/README.md— responsibilities, stack, source layout, local development (including the Docker-daemon/Postgres/Valkey prerequisite this service needs, unlikeservices/realtime's standalone dev loop), environment variables, testing, linting.docs/ai-agent/agent-runner-service.mdto document the per-conversation serialization guarantee, the registry ownership-token safety, the chat-sandbox teardown guard, the provider-id alias table, theclone_repositorypath-safety guard, and a previously-undocumented env var (PACA_MCP_DEV_SOURCE_DIR).Test plan
go build ./...andgo vet ./...clean acrossservices/agent-runnergo test -race ./...clean, including new regression tests for every fixapps/mcp:bun run test(551 tests) andtsc --noEmitcleanapps/web:bun run test(554 tests) andtsc --noEmitclean