Files
multica/server/pkg/agent/dim.go
T
46b5d9e6d1 MUL-6658 fix(agent): own the runtime process tree on every backend (#7522) (#7531)
* fix(agent): own the runtime process tree on every backend

Cancelling a task on Windows left the agent running. The daemon marked
the task cancelled, revoked its mat_ token, and killed what it thought
was the CLI — but on Windows the direct child is usually a cmd.exe shim
and the real CLI is already a grandchild, so it survived. One reporter's
orphan kept working for 40 minutes, read the daemon owner's profile PAT
after its own token started returning 401, and posted comments as the
member (GH #7522).

Whole-tree ownership already existed on both platforms. It was opt-in,
and opt-in rotted: 8 of the 27 places this package starts a process asked
for a Unix process group, and 3 of the 23 backends took Windows Job
Object ownership. Adding claude to that list would have left 19 backends
with the same hole, so the fix is structural instead.

- newRuntimeCmd, at the single point where a runtime process is built,
  now puts every command in its own process group and replaces os/exec's
  leader-only Cancel with a group-wide kill. A backend that wants a
  graceful shutdown still assigns its own cmd.Cancel and wins. This also
  covers the --version and model-discovery probes, which cmd.Output()
  runs without ever reaching a start helper.
- startOwnedProcessTree is now the only way this package starts a
  process, so Windows Job Object ownership is not something a backend
  can forget. Each start is paired with releaseProcessGroup after the
  reap, which drops the job handle and kills whatever outlived it.
- Two structural tests enforce both halves, the way
  TestOnlyLaunchGoSpawnsRuntimeProcesses already enforces the argv
  boundary: a new backend calling cmd.Start fails, and one that takes
  ownership without releasing it fails.

The Unix half is #5918 for the 20 backends the original fix did not
reach: their CLI was never in its own group at all, so no group-wide
signal could have reached it.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): own the process tree for synchronous probes too

Review found the ownership boundary had a hole the PR claimed to cover.
os/exec's Run, Output and CombinedOutput call Start themselves, so a
probe written with cmd.Output() never reaches startOwnedProcessTree and
owns nothing on Windows — where the direct child of a `--version` probe
is typically the npm shim and the real CLI is already a grandchild. That
is the same escape #7522 was reported for, in 15 call sites nobody was
looking at; detectCLIVersion's own comment already describes a broken
CLI leaving grandchildren that hold the stdout pipe.

- runOwned / outputOwned / combinedOutputOwned give a synchronous probe
  the same start, reap and release a task launch gets. They keep the
  stdlib contract: stdout returned, stderr attached to the ExitError,
  combined output through a single writer so os/exec gives it one pipe.
  The stderr sample is bounded, as os/exec bounds its own.
- All 15 probe sites route through them, and the structural guard now
  rejects Run/Output/CombinedOutput alongside Start.

The release guard did not hold its invariant either: it only asked
whether a file contained any releaseProcessGroup, so a second unreleased
start in a file that already had a pair would pass — and models.go is
exactly that shape. It now works per function: one owned start each,
paired with at least one release. Equal counts would be the wrong rule,
since dsh's Execute legitimately releases on two exit paths.

Both guards are now pure functions over an AST, with tests that feed
them synthetic sources — including the second-unreleased-start case that
the file-level version missed — so the guards fail closed without
needing a real regression committed to find out.

Adds the Windows probe regression (descendant must be in the job and die
with a cancelled probe) plus unix equivalents, and names the consequence
in the degraded-ownership warning, which is the only signal an operator
gets that cancellation on that host is back to killing the leader alone.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): bound a finished probe's wait on pipes its descendants hold

Review caught that runOwned could wait for its own cleanup and never
return, and it reproduces: a descendant that inherited the output pipes
holds them open after the leader exits, cmd.Wait blocks until the copy
goroutines see EOF, and what would close those pipes is the release that
runs after Wait.

Cancellation breaks the cycle, which is why every test in the last round
missed this — they all cancelled first, and they all set WaitDelay by
hand. Normal exit has nothing to break it, and checkOpenclawVersion runs
on the caller's context before runContext establishes any task timeout,
so a version wrapper that exits cleanly while leaving a pipe-holding
descendant would hang the task until a human cancelled it.

runOwned now fills in a bounded WaitDelay when the caller left it zero —
Go defines WaitDelay for precisely "a child process that exits but
leaves its I/O pipes unclosed" — and signals the group before dropping
ownership, so the descendant is reaped on Unix too, where releasing a
process group is a no-op. A caller with its own bound keeps it;
detectCLIVersion has set one by hand since MUL-3812 for this same shape.

Adds the normal-exit regression on both platforms: the leader exits
holding nothing, the descendant keeps the pipes, and outputOwned has to
return in bounded time with the descendant gone. The Windows descendant
spawner gains a spawn-and-exit mode and now hands its stdio to the
descendant, which is what makes it hold the pipes at all.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 16:27:46 +08:00

681 lines
25 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
// dimBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. `acp` is the protocol
// subcommand that drives the ACP JSON-RPC transport; overriding it
// would break the daemon↔Dim communication contract. `--auth-setup`
// and `--remote` switch the CLI into modes that never start the ACP
// server (interactive login / removed remote flow).
var dimBlockedArgs = map[string]blockedArgMode{
"acp": blockedStandalone,
"--auth-setup": blockedStandalone,
"--remote": blockedStandalone,
"--help": blockedStandalone,
"-h": blockedStandalone,
}
// dimBackend implements Backend by spawning `dim acp` and
// communicating via the standard ACP (Agent Client Protocol) JSON-RPC 2.0
// over stdin/stdout.
//
// Dim (dimcode) exposes its agent loop over ACP through the `dim acp`
// subcommand. The protocol surface matches the shared hermesClient ACP
// transport used by Hermes/Kimi/Kiro/Traecli/QwenPaw — only the binary,
// the session bootstrap, and the tool-name extraction differ.
//
// Notable contract with Dim's ACP server:
// - `initialize` advertises no authMethods, so no `authenticate` step is
// needed; the CLI uses the user's existing Dim OAuth login.
// - `session/new` requires an absolute cwd (relative paths are rejected).
// - The ACP server hardcodes a read-only permission preset at session
// creation, which would silently deny every file write and process
// spawn. The backend therefore issues `session/set_config_option`
// (permission → full-access, mode → agent) right after session/new so
// Multica agents can do real work. A resumed session retains these
// settings across `session/load`, so set_config_option is re-applied on
// both fresh and resumed sessions (idempotent). Model override is handled through the standard
// `session/set_model` RPC; the model catalog is advertised by
// `session/new` under models.availableModels / configOptions.
// - `session/load` resumes a prior session across processes: dim 0.3.10+
// releases its per-process session lock within ~5s of the owning process
// exiting, so a follow-up run in a fresh `dim acp` process can continue
// the conversation. Earlier 0.3.x builds bound sessions to the creating
// process permanently; on those the load fails and ResumeRejected lets
// the daemon retry fresh.
// - Tool names are already lowercase ("write", "read", "exec"); the
// shared kimiToolNameFromTitle normalisation keeps the UI identifiers
// stable.
type dimBackend struct {
cfg Config
}
var (
dimReaderDrainGrace = 2 * time.Second
dimNotificationQuietTime = 250 * time.Millisecond
// dimProcessWaitTimeout bounds how long the deferred cleanup waits for
// the child to exit after cancel+stdin-close before force-killing it. A
// child that ignores both should not be able to hang Result delivery.
dimProcessWaitTimeout = 5 * time.Second
// dimSessionCloseTimeout bounds the best-effort session/close sent before
// tearing down the process. It is deliberately short so a stuck close
// cannot delay Result delivery; the next run's session/load is still safe
// because dim releases the lock on its own after ~5s.
dimSessionCloseTimeout = 2 * time.Second
// dimSessionLoadRetryAttempts bounds the number of retries when
// session/load reports "held by another process" — the prior process's
// lock has not been released yet. With graceful session/close this is
// rare (the lock releases immediately), but a force-killed process can
// take up to ~5s. The retry loop covers that window instead of silently
// starting a fresh session and losing the conversation.
dimSessionLoadRetryAttempts = 3
dimSessionLoadRetryDelay = 2 * time.Second
)
// dimMinVersion is the minimum dim version that supports cross-run session
// resume via session/load. Earlier builds bind sessions to the creating
// process permanently.
const dimMinVersion = "0.3.10"
// extractACPAgentVersion pulls agentInfo.version out of an initialize
// response. Returns "" if the field is absent (e.g. an older runtime that
// does not report it).
func extractACPAgentVersion(result json.RawMessage) string {
var resp struct {
AgentInfo struct {
Version string `json:"version"`
} `json:"agentInfo"`
}
if err := json.Unmarshal(result, &resp); err != nil {
return ""
}
return resp.AgentInfo.Version
}
// dimVersionSupported reports whether the dim version meets the minimum
// required for cross-run session resume. Uses semver-ish comparison
// (major.minor.patch). Fail-closed: an empty or non-parseable version is
// rejected (returns false) rather than assumed supported, so a runtime that
// cannot prove it is >= 0.3.10 is blocked from session resume (review #3).
func dimVersionSupported(version string) bool {
version = strings.TrimPrefix(version, "v")
parts := strings.Split(version, ".")
if len(parts) < 3 {
return false // unparseable — fail-closed, do not assume supported
}
var maj, min, pat int
if _, err := fmt.Sscanf(parts[0], "%d", &maj); err != nil {
return false
}
if _, err := fmt.Sscanf(parts[1], "%d", &min); err != nil {
return false
}
// patch may have a suffix (e.g. "10-beta"); Sscanf stops at the first
// non-digit, which is what we want.
if _, err := fmt.Sscanf(parts[2], "%d", &pat); err != nil {
return false
}
var reqMaj, reqMin, reqPat int
fmt.Sscanf(dimMinVersion, "%d.%d.%d", &reqMaj, &reqMin, &reqPat)
if maj != reqMaj {
return maj > reqMaj
}
if min != reqMin {
return min > reqMin
}
return pat >= reqPat
}
// dimMessageStream serializes sends and the final close so a late stdout
// reader cannot send on a closed channel. Mirrors grok/traecli/qoder.
type dimMessageStream struct {
ch chan Message
mu sync.Mutex
closed bool
}
func newDimMessageStream(size int) *dimMessageStream {
return &dimMessageStream{ch: make(chan Message, size)}
}
func (s *dimMessageStream) send(msg Message) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
trySend(s.ch, msg)
}
func (s *dimMessageStream) close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
close(s.ch)
}
func (b *dimBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "dim"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("dim executable not found at %q: %w", execPath, err)
}
// Translate the agent's mcp_config (Claude-style object of objects) into
// the array shape ACP session/new and session/load expect. Fail closed on
// malformed JSON so the launch surfaces the real error instead of silently
// dropping every MCP server.
mcpServers, err := buildACPMcpServers(opts.McpConfig, b.cfg.Logger)
if err != nil {
return nil, fmt.Errorf("dim: invalid mcp_config: %w", err)
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
dimArgs := append([]string{"acp"}, filterCustomArgs(opts.CustomArgs, dimBlockedArgs, b.cfg.Logger)...)
cmd := b.cfg.commandAt(execPath).exec(runCtx, dimArgs...)
hideAgentWindow(cmd)
// Take over context cancellation: the default kills the group the instant
// runCtx is done, leaving no chance to shut the ACP session down first. We
// instead drive a group-wide SIGKILL from the deferred cleanup below.
// Returning nil keeps os/exec from racing us with its own kill.
cmd.Cancel = func() error { return nil }
cmd.WaitDelay = 10 * time.Second
b.cfg.logAgentCommand(cmd, newAgentCommandLogArgs(dimArgs, trustAgentCommandPositional(0, "acp")))
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("dim stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("dim stdin pipe: %w", err)
}
// StderrPipe + an explicit copier give us a join point (`stderrDone`) that
// fires before the failure-promotion decision; see hermes.go for why the
// io.MultiWriter form races with stopReason=end_turn under load.
providerErr := newACPProviderErrorSniffer("dim")
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("dim stderr pipe: %w", err)
}
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
cancel()
return nil, fmt.Errorf("start dim: %w", err)
}
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[dim:stderr] "), providerErr)
stderrDone := make(chan struct{})
go func() {
defer close(stderrDone)
_, _ = io.Copy(stderrSink, stderr)
}()
b.cfg.Logger.Info("dim acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
msgStream := newDimMessageStream(256)
resCh := make(chan Result, 1)
// Dim streams interim narration and the final answer as the same
// agent_message_chunk type; the tracker keeps only the post-tool-call
// block for Result.Output while retaining the full text for error
// detection.
var deliverable acpDeliverableTracker
promptDone := make(chan hermesPromptResult, 1)
activity := make(chan struct{}, 1)
c := &hermesClient{
cfg: b.cfg,
stdin: stdin,
pending: make(map[int]*pendingRPC),
pendingTools: make(map[string]*pendingToolCall),
onActivity: func() {
select {
case activity <- struct{}{}:
default:
}
},
onMessage: func(msg Message) {
if msg.Type == MessageToolUse {
// Re-normalise tool titles the same way kimi/traecli do so the
// UI sees consistent snake_case names ("write" → "write_file").
msg.Tool = kimiToolNameFromTitle(msg.Tool)
}
deliverable.observe(msg)
msgStream.send(msg)
},
onPromptDone: func(result hermesPromptResult) {
select {
case promptDone <- result:
default:
}
},
}
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := newAgentStreamScanner(stdout)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
c.handleLine(line)
}
c.closeAllPending(fmt.Errorf("dim process exited"))
}()
go func() {
defer cancel()
defer msgStream.close()
defer close(resCh)
// Cleanup ordering matters: cancel the run context first so
// exec.CommandContext kills the child if it is still alive, THEN
// close stdin and wait. The previous order (stdin.Close → cmd.Wait →
// cancel) left cancel() unreachable when a child ignored stdin EOF
// and never exited, hanging cmd.Wait() forever and never closing
// resCh. cmd.Wait is bounded so a still-stuck child cannot block the
// final Result either.
defer func() {
// Signal the entire process group (the agent CLI plus any tool
// subprocesses it spawned) BEFORE closing stdin. exec.
// CommandContext's cancel is disabled (cmd.Cancel returns nil)
// so we own all signalling. If we close stdin first, the shell
// exits on EOF and its process group dissolves before we can
// SIGKILL the descendants, orphaning them to PPID=1.
signalProcessGroup(cmd, syscall.SIGKILL)
cancel()
stdin.Close()
waitDone := make(chan struct{})
go func() { _ = cmd.Wait(); close(waitDone) }()
select {
case <-waitDone:
case <-time.After(dimProcessWaitTimeout):
}
waitProcessGroupGone(cmd, dimProcessWaitTimeout)
// Release the Windows Job Object ownership handle (no-op on Unix)
// so the OS can finalize any processes that were attached to it.
releaseProcessGroup(cmd)
}()
startTime := time.Now()
finalStatus := "completed"
var finalError string
var sessionID string
// Set when the ACP runtime refuses the session we asked to
// resume. Only that is curable by starting a fresh session, so
// handshake/network failures below must leave it false.
var resumeRejected bool
effectiveModel := strings.TrimSpace(opts.Model)
initResult, err := c.request(runCtx, "initialize", map[string]any{
"protocolVersion": 1,
"clientInfo": map[string]any{
"name": "multica-agent-sdk",
"version": "0.2.0",
},
"clientCapabilities": map[string]any{},
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("dim initialize failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
// Require dim >= 0.3.10 (fail-closed): earlier builds bind sessions to
// the creating process permanently, so cross-run session/load fails with
// "held by another process" and follow-up runs lose context. 0.3.10
// releases the lock on graceful exit (session/close) or after ~5s. A
// version that is empty or cannot be parsed is rejected rather than
// assumed supported, so a runtime that cannot prove it is >= 0.3.10 is
// blocked from session resume (review #3).
dimVersion := extractACPAgentVersion(initResult)
if !dimVersionSupported(dimVersion) {
finalStatus = "failed"
if dimVersion == "" {
finalError = "dim did not report an agent version: cross-run session resume requires dim >= 0.3.10 (0.3.10+ releases the per-process session lock on exit); please upgrade dimcode"
} else {
finalError = fmt.Sprintf("dim %s is too old: cross-run session resume requires dim >= 0.3.10 (0.3.10+ releases the per-process session lock on exit); please upgrade dimcode", dimVersion)
}
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
// Drop MCP entries whose remote transport the runtime didn't advertise.
// See hermes.go for why sending an unsupported transport tanks session/new.
mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "dim", b.cfg)
cwd := opts.Cwd
if cwd == "" {
cwd = "."
}
// Dim (dimcode 0.3.10+) releases its per-process session lock within
// ~5s of the owning process exiting (graceful close or SIGTERM), so a
// follow-up run in a fresh `dim acp` process can resume the prior
// session via the standard ACP `session/load`. Earlier 0.3.x builds
// bound sessions to the creating process permanently; on those the
// load fails with "held by another process" and ResumeRejected lets
// the daemon retry on a fresh session.
//
// A loaded session retains its permission/mode config (verified: a
// full-access session stays full-access after load), but
// set_config_option is re-applied on both fresh and resumed sessions
// to guard against a partially configured session being resumed
// (review #4).
var freshSession bool
var sessionResult json.RawMessage
if opts.ResumeSessionID != "" {
// Retry session/load when the prior process's lock has not been
// released yet ("held by another process"). With graceful
// session/close the lock releases immediately, but a force-killed
// process can take up to ~5s. Retry instead of silently starting
// a fresh session and losing the conversation (review #1).
var result json.RawMessage
var loadErr error
loadRetry:
for attempt := 0; attempt <= dimSessionLoadRetryAttempts; attempt++ {
result, loadErr = c.request(runCtx, "session/load", map[string]any{
"cwd": cwd,
"sessionId": opts.ResumeSessionID,
"mcpServers": mcpServers,
})
if loadErr == nil {
break
}
if isACPSessionNotFound(loadErr) {
break // session is gone — no point retrying
}
if !isACPHeldByProcess(loadErr) {
break // different error — let the normal failure path handle it
}
if attempt < dimSessionLoadRetryAttempts {
b.cfg.Logger.Warn("dim session/load: lock not yet released, retrying",
"backend", "dim",
"attempt", attempt+1,
"delay", dimSessionLoadRetryDelay.String(),
)
select {
case <-time.After(dimSessionLoadRetryDelay):
case <-runCtx.Done():
loadErr = fmt.Errorf("dim session/load cancelled: %w", runCtx.Err())
break loadRetry
}
}
}
err := loadErr
if err != nil {
if isACPSessionNotFound(err) {
b.cfg.Logger.Warn("dim resumed session not found; the daemon will retry fresh",
"backend", "dim",
"requested_session", opts.ResumeSessionID,
)
resumeRejected = true
resCh <- Result{Status: "failed", Error: fmt.Sprintf("dim session/load: %v", err), DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
return
}
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("dim timed out during session/load: %v", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = fmt.Sprintf("dim aborted: %v", err)
} else {
finalStatus = "failed"
finalError = fmt.Sprintf("dim session/load failed: %v", err)
}
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
return
}
var changed bool
sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, result)
sessionResult = result
if changed {
b.cfg.Logger.Warn("dim returned a different session id on resume — original was likely lost; continuing with the new id",
"backend", "dim",
"requested", opts.ResumeSessionID,
"actual", sessionID,
)
}
if effectiveModel == "" {
effectiveModel = extractACPCurrentModelID(result)
}
} else {
freshSession = true
result, err := c.request(runCtx, "session/new", map[string]any{
"cwd": cwd,
"mcpServers": mcpServers,
})
if err != nil {
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("dim timed out during session/new: %v", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = fmt.Sprintf("dim aborted: %v", err)
} else {
finalStatus = "failed"
finalError = fmt.Sprintf("dim session/new failed: %v", err)
}
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
return
}
sessionID = extractACPSessionID(result)
sessionResult = result
if sessionID == "" {
finalStatus = "failed"
finalError = "dim session/new returned no session ID"
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
return
}
if effectiveModel == "" {
effectiveModel = extractACPCurrentModelID(result)
}
}
c.sessionID = sessionID
// Early session pin so a cancelled run still preserves resume pointer.
msgStream.send(Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
b.cfg.Logger.Info("dim session ready", "session_id", sessionID, "resumed", !freshSession)
// Dim's ACP server hardcodes a read-only permission preset at session
// creation. Raise it to full-access and pin agent mode. This runs on
// BOTH fresh and resumed sessions (review #4): a resumed session whose
// first run failed partway through config would carry a half-configured
// state into the resume. set_config_option is idempotent. On failure we
// close the session and abort.
for _, cfgOpt := range []struct {
id string
value string
}{
{"permission", "full-access"},
{"mode", "agent"},
} {
if _, err := c.request(runCtx, "session/set_config_option", map[string]any{
"sessionId": sessionID,
"configId": cfgOpt.id,
"value": cfgOpt.value,
}); err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("dim could not set session config %s=%s: %v", cfgOpt.id, cfgOpt.value, err)
// Best-effort close so a partially configured session is not
// left behind for the next resume to inherit.
closeCtx, closeCancel := context.WithTimeout(context.Background(), dimSessionCloseTimeout)
_, _ = c.request(closeCtx, "session/close", map[string]any{"sessionId": sessionID})
closeCancel()
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), SessionID: sessionID, ResumeRejected: resumeRejected}
return
}
b.cfg.Logger.Info("dim session config set", "config", cfgOpt.id, "value", cfgOpt.value, "session_id", sessionID)
}
if opts.Model != "" {
if _, err := c.request(runCtx, "session/set_model", map[string]any{
"sessionId": sessionID,
"modelId": opts.Model,
}); err != nil {
b.cfg.Logger.Warn("dim set_session_model failed", "error", err, "requested_model", opts.Model)
finalStatus = "failed"
finalError = fmt.Sprintf("dim could not switch to model %q: %v", opts.Model, err)
// Close the session so a partially configured one (permission/mode
// set, model not) is not left for the next resume to inherit.
closeCtx, closeCancel := context.WithTimeout(context.Background(), dimSessionCloseTimeout)
_, _ = c.request(closeCtx, "session/close", map[string]any{"sessionId": sessionID})
closeCancel()
resCh <- Result{
Status: finalStatus,
Error: finalError,
DurationMs: time.Since(startTime).Milliseconds(),
SessionID: sessionID,
ResumeRejected: resumeRejected,
}
return
}
b.cfg.Logger.Info("dim session model set", "model", opts.Model)
}
// Apply the thinking level (if any) via the ACP session config. Dim
// advertises a `thought_level` configOption in session/new (auto/high/
// max), so it joins the acpCatalogThinkingProviders list. The effort
// option may depend on the current model, so apply it after set_model.
applyACPEffortOption(runCtx, c.request, "dim", b.cfg.Logger,
sessionID, sessionResult, opts.ThinkingLevel, opts.Model == "")
userText := prompt
if opts.SystemPrompt != "" {
userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
}
_, err = c.request(runCtx, "session/prompt", map[string]any{
"sessionId": sessionID,
"prompt": []map[string]any{
{"type": "text", "text": userText},
},
})
if err != nil {
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("dim timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else {
finalStatus = "failed"
finalError = fmt.Sprintf("dim session/prompt failed: %v", err)
}
} else {
// Check if we got a promptDone result from the response parsing
// (non-blocking — the response is parsed synchronously during
// c.request, so promptDone is usually already ready).
select {
case pr := <-promptDone:
if pr.stopReason == "cancelled" {
finalStatus = "aborted"
finalError = "dim cancelled the prompt"
}
if effectiveModel == "" {
effectiveModel = pr.modelID
}
c.mergeUsage(pr.usage)
default:
}
// Give the stdout reader a bounded chance to consume notifications
// dim may emit just after session/prompt returns (agent_message_chunk,
// usage updates). Closing stdin at the response boundary otherwise
// races the reader and truncates the final text — the same race
// fixed for hermes (TestHermesBackendDrainsLateFinalNotification).
waitForACPNotificationQuiescence(runCtx, activity, readerDone, dimNotificationQuietTime, dimReaderDrainGrace)
}
duration := time.Since(startTime)
b.cfg.Logger.Info("dim finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
// Best-effort session/close before tearing down the process: it lets
// dim release the per-process session lock promptly (the next run can
// resume immediately) instead of waiting for the 5s idle-release
// timeout. Bounded and best-effort — a miss just means the next run
// waits up to 5s for the lock.
if sessionID != "" {
closeCtx, closeCancel := context.WithTimeout(context.Background(), dimSessionCloseTimeout)
_, _ = c.request(closeCtx, "session/close", map[string]any{"sessionId": sessionID})
closeCancel()
}
stdin.Close()
cancel()
// Dim's ACP server may keep the process — and the stdout/stderr
// pipes — open briefly after session/prompt returns. Bound the drain.
drainCtx, drainCancel := context.WithTimeout(context.Background(), dimReaderDrainGrace)
select {
case <-readerDone:
case <-drainCtx.Done():
}
select {
case <-stderrDone:
case <-drainCtx.Done():
}
drainCancel()
finalOutput, providerErrorOutput := deliverable.result()
// Promote completed→failed when stderr or the agent text stream show a
// terminal upstream-LLM failure (auth / rate-limit / HTTP 4xx). It reads
// the full text stream, not the deliverable, so a give-up turn that
// lands before a tool call stays visible.
finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, providerErrorOutput, providerErr)
u := c.accumulatedUsage()
var usageMap map[string]TokenUsage
if acpUsagePresent(u) {
model := effectiveModel
if model == "" {
model = "unknown"
}
usageMap = map[string]TokenUsage{model: u}
}
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
ResumeRejected: resumeRejected,
Usage: usageMap,
}
}()
return &Session{Messages: msgStream.ch, Result: resCh}, nil
}