fix(teamloader): propagate session working dir to toolsets#3657
Conversation
When a session was created with a per-session working directory (via teamloader.WithWorkingDir), the loader read the option to resolve parentDir but never wrote the value onto runConfig. Toolsets (shell, filesystem, ...) read runConfig.WorkingDir directly at construction time, so they ended up with an empty working dir and fell back to the docker-agent process CWD. Callers that spawn docker-agent as a long-lived server and open per-session working dirs over the API saw shell / filesystem tools operate on the server's own data directory instead of the directory the user opened. Regression from 65bffd7 ("decouple WorkingDir from RuntimeConfig in SessionManager"): the write-back was moved into a local variable and never mirrored onto runConfig, while the neighbouring fields (Models, Providers, ProviderRegistry) kept their in-place mutation. This restores the write-back on the merged value so per-session working directories reach the tools.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
| // Load agents | ||
| workingDir := cmp.Or(loadOpts.workingDir, runConfig.WorkingDir) | ||
| // Toolsets read runConfig.WorkingDir directly, so propagate it here. | ||
| runConfig.WorkingDir = workingDir |
There was a problem hiding this comment.
[MEDIUM] Conditional WorkingDir mutation may produce stale value on repeated Load calls
runConfig is a shared pointer and LoadWithConfig already mutates it in-place for Models, Providers, and ProviderRegistry — so the pattern itself is intentional. However, this new mutation has a subtly different character: the value written is cmp.Or(loadOpts.workingDir, runConfig.WorkingDir), which is a conditional merge of a per-call option into the shared struct.
Trigger path: a caller that reuses the same *RuntimeConfig across multiple Load calls with different WithWorkingDir options:
// First call: WithWorkingDir("/tmp/session-1")
Load(ctx, src, runConfig, WithWorkingDir("/tmp/session-1"))
// → runConfig.WorkingDir is now "/tmp/session-1"
// Second call: no WithWorkingDir → loadOpts.workingDir == ""
Load(ctx, src, runConfig)
// → cmp.Or("", "/tmp/session-1") = "/tmp/session-1" ← stale leakThe second call inherits the first session's directory instead of the daemon's original CWD, which is the opposite of the symptom this PR aims to fix.
Mitigation: if runConfig is expected to be reused across sessions (e.g. in docker-agent serve api), consider restoring WorkingDir after the call, or accepting only a fresh runConfig per session. The current tests do not exercise this reuse pattern with differing WithWorkingDir values, so the scenario is untested.
There was a problem hiding this comment.
Good catch — the merged value would leak into a subsequent no-WithWorkingDir call on the same *RuntimeConfig.
Fixed in bfd2a0f by saving runConfig.WorkingDir at entry and defer-restoring it at exit, so toolsets see the merged directory during the call while the shared runConfig is left as the caller found it. Added TestLoadWithConfig_WithWorkingDirDoesNotLeak to pin the two-sequential-Load scenario.
There was a problem hiding this comment.
Perfect fix — the defer-restore pattern ensures runConfig.WorkingDir is visible to toolsets during the call while leaving the shared struct exactly as the caller provided it. The test covers the precise two-sequential-Load scenario where the stale leak would have occurred.
Validated that the defer runs on both normal and error paths, so even a mid-load failure won't leak the merged value. Thanks for the thorough follow-up!
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The fix correctly restores the missing runConfig.WorkingDir = workingDir write-back that was lost in commit 65bffd71. The ordering (before parentDir resolution and toolset construction at line 240+) is right, and cmp.Or(loadOpts.workingDir, runConfig.WorkingDir) correctly preserves a pre-set WorkingDir when WithWorkingDir is not explicitly passed.
Mutation safety: The in-place mutation pattern is consistent with the pre-existing writes to runConfig.Models, runConfig.Providers, and runConfig.ProviderRegistry already present in this function. In the recursive loadExternalAgent path, WithWorkingDir is not propagated to sub-load opts, so cmp.Or("", runConfig.WorkingDir) is a no-op write-back. The session manager creates a fresh runConfig per session, ruling out cross-session pollution.
Minor note (no action required): There's no test that exercises the WithWorkingDir → runConfig.WorkingDir propagation path directly; existing tests set runConfig.WorkingDir directly. A regression test calling LoadWithConfig with WithWorkingDir and asserting the resulting runConfig.WorkingDir would guard against re-introducing the original regression from 65bffd71.
Callers that reuse a shared *RuntimeConfig across sessions (e.g. docker-agent serve api) can pass different WithWorkingDir values per Load. Mutating runConfig.WorkingDir in-place would leak the first session's directory into subsequent calls whose merged value falls back to runConfig.WorkingDir. Save the caller's value at entry and defer-restore it at exit so toolsets see the merged directory while the shared runConfig is left as the caller found it. Adds a regression test.
Why
teamloader.LoadWithConfigaccepts a per-session working directory viaWithWorkingDir, but the value was only used to resolveparentDirlocally — it was never written back ontorunConfig.Toolsets read
runConfig.WorkingDirdirectly at construction time:So the option was silently dropped for every tool:
shell,filesystem, and any other toolset that keys offrunConfig.WorkingDirran in the docker-agent process CWD instead of the session's directory.Symptoms in a long-lived
docker-agent serve apiprocess:shellandfilesystemcalls in a session created withworking_dir: /home/user/projectend up operating on the server's own CWD (e.g. wherever the daemon was launched from), not/home/user/project.Regression
65bffd71f("decouple WorkingDir from RuntimeConfig in SessionManager") moved theWorkingDirwrite-back into a local variable inside the loader while keeping the neighbouring writes (Models,Providers,ProviderRegistry) in place. This restores the write-back on the mergedworkingDirlocal so it reaches the toolsets, without reintroducing the previousrunConfig.Clone()at the call site.