Apply the suggested optimization on top of the previous race fix.
- internal/process/manager.go: LocalManager.sessionsMu is now an
RWMutex. Read paths (Status, Subscribe lookup, Stdin lookup,
ExitStatus lookup) take RLock; mutating paths (Start, Stop,
Restart) take Lock. exited is now a sync.Map instead of a
hand-rolled map+mutex; reads (IsExited) are lock-free, writes
(MarkAsExited from waitExit, ClearExited on Start/Stop/Restart)
are atomic. Added IsExited / MarkAsExited / ClearExited helpers.
No behavior change. The previous race fix (the only one in the
package) is preserved: waitExit remains the sole caller of
sess.Cmd.Wait(), and all other code paths read the exited marker
rather than cmd.ProcessState.
- ACP: nothing to do. The ACP service talks to opencode through
process.Manager; it never reads cmd.ProcessState directly. The
process package race fix already covers the ACP code path.
Verified: go test -race -count=2 ./... clean across the repo.
Note: at this app's concurrency level (tens of workspaces, accessed
occasionally), neither sync.RWMutex nor sync.Map measurably beats
the previous map+mutex pair — the critical sections are O(1)
lookups with no contention. The change is mostly stylistic
(removes one lock, fewer deadlock surfaces, more idiomatic Go).
E2E: start / status(running) / duplicate(409) / stop / status(stopped)
/ restart / status(running) / stop all behave correctly.
The race: os/exec writes cmd.ProcessState inside cmd.Wait(), which
runs in the waitExit goroutine. Start, Status, Subscribe, and
Restart all read cmd.ProcessState from other goroutines to decide
'still running?'. That's a textbook Go data race — fixed by
TestStartRejectsDuplicateRunningSession under -race.
Fix: port the same pattern the shell package uses for its multi-shell
refactor. The manager now owns an exited map[string]struct{} guarded
by exitedMu. waitExit is the ONLY place that calls cmd.Wait(); after
Wait() returns it records exited[workspaceID]. Everyone else checks
the exited map instead of reading cmd.ProcessState.
- internal/process/manager.go: add exited + exitedMu to LocalManager;
Start conflict check, Status, Subscribe, Restart, Stop all consult
the exited map; Start clears the entry on new process; Stop and
Restart delete the entry on stop; waitExit sets it after Wait().
- go.mod / go.sum: tidied (uuid v1.6.0 was already direct dep from
the shell refactor).
go test -race -count=3 ./... clean across the whole repo.
go test -race -count=5 ./internal/process/... clean.
Adds a minimal but real ACP stack for the opencode process:
- pkg/config: process.args default ["acp"] (opencodeCommand still "opencode")
- internal/process: NewManager(command, args) — exec.Command uses args
- internal/acp (new): NDJSON transport + JSON-RPC client over the existing
process stdio. Implements initialize / session/new / session/prompt /
session/cancel. Serves fs/read_text_file and fs/write_text_file from the
workspace's fs.FileSystem. terminal/* requests get MethodNotFound.
- internal/service/acp_service: per-workspace Client + mutex; starts the
process on first prompt; transparently re-init on restart.
- internal/api/acp_handler: GET /acp/status, GET /acp/history,
POST /acp/prompt, POST /acp/cancel.
- internal/model/acp: API DTOs.
- internal/acp/client_test: NDJSON split-lines, request/response correlation,
notification dispatch, agent-initiated request handling (fs + terminal).
Existing process WS endpoint and Shell subsystem are unchanged.
Conversation: 019f354c-a51b-7ec3-83ad-c647e9b50b19