Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/past-failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- Symptom: production OpenClaw sessions failed with `ACP_SESSION_INIT_FAILED` and `ACP metadata is missing for agent:main:acp:<id>`, instructing the operator to recreate the ACP session with `/acp spawn`.
- Cause: the node daemon reused the server session id as the ACPX session name, but a stale ACPX session could exist without the backing ACP metadata OpenClaw needs to initialize.
- Mitigation: the Go ACPX runner now treats that exact metadata-missing failure as recoverable, recreates the named ACPX session once with `sessions new --name <session>`, and retries `sessions ensure`. A regression test covers the failure, recreate, and retry sequence.
- Mitigation: the Go ACPX runner now treats that exact metadata-missing failure as recoverable both during `sessions ensure` and during the first prompt run, recreates the named ACPX session once with `sessions new --name <session>`, and retries. Regression tests cover the ensure path and the OpenClaw prompt-time failure shape.

## 2026-05-12: Installer rejected valid Node 24 runtimes

Expand Down
19 changes: 15 additions & 4 deletions internal/acpx/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ func (Runner) Run(
if err := (Runner{}).Ensure(ctx, request); err != nil {
return nil, fmt.Errorf("ensure acpx session: %w", err)
}
output, err := runStreamingCommand(ctx, command, buildPromptArgs(request), request.WorkingDir, request.Env, request.Stdin, onStdoutLine)
if err == nil || request.Session == "" || !isMissingACPMetadataError(err) {
return output, err
}
if newErr := runCommand(ctx, command, buildSessionNewArgs(request), request.WorkingDir, request.Env, ""); newErr != nil {
return output, fmt.Errorf("%w; recreate acpx session after missing ACP metadata: %v", err, newErr)
}
return runStreamingCommand(ctx, command, buildPromptArgs(request), request.WorkingDir, request.Env, request.Stdin, onStdoutLine)
}

Expand Down Expand Up @@ -223,6 +230,7 @@ func runStreamingCommand(

var (
stdoutBuf bytes.Buffer
stderrBuf bytes.Buffer
mu sync.Mutex
wg sync.WaitGroup
)
Expand All @@ -246,16 +254,19 @@ func runStreamingCommand(
go func() {
defer wg.Done()
// Drain stderr so the child process never blocks on a full pipe.
// We intentionally discard the contents: acpx stderr is operator noise
// (banners, [acpx] tokens summary, http debug) and never carries protocol
// state. Surfacing it would let it leak into chat transcripts again.
_, _ = io.Copy(io.Discard, stderr)
// Keep it out of stdout/chat events, but retain it for errors because
// provider init failures such as OpenClaw's ACP metadata error arrive
// there.
_, _ = io.Copy(&stderrBuf, stderr)
}()

err = cmd.Wait()
wg.Wait()
output := stdoutBuf.Bytes()
if err != nil {
if stderrText := strings.TrimSpace(stderrBuf.String()); stderrText != "" {
return output, fmt.Errorf("run acpx: %w: %s", err, stderrText)
}
return output, fmt.Errorf("run acpx: %w", err)
}
return output, nil
Expand Down
78 changes: 78 additions & 0 deletions internal/acpx/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,38 @@ func TestRunnerRecreatesSessionWhenACPMetadataIsMissing(t *testing.T) {
}
}

func TestRunnerRecreatesSessionWhenOpenClawPromptACPMetadataIsMissing(t *testing.T) {
t.Parallel()

statePath := filepath.Join(t.TempDir(), "state")
command, args := helperCommand(t, "recover-openclaw-prompt-missing-acp-metadata")
output, err := (Runner{}).Run(context.Background(), RunRequest{
Command: command,
Args: args,
Agent: "openclaw",
Session: "agent-main-acp-d9d29d47",
Stdin: "review this",
Env: []string{
"GO_WANT_HELPER_PROCESS=1",
"ACP_METADATA_RECOVERY_STATE=" + statePath,
},
}, nil)
if err != nil {
t.Fatalf("run: %v", err)
}
if !strings.Contains(string(output), "prompt-ok") {
t.Fatalf("output = %q, want prompt-ok", output)
}

bytes, err := os.ReadFile(statePath)
if err != nil {
t.Fatal(err)
}
if got, want := strings.TrimSpace(string(bytes)), "ensure\nprompt\nnew\nprompt"; got != want {
t.Fatalf("commands = %q, want %q", got, want)
}
}

func TestRunnerCancelsProcess(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -146,6 +178,8 @@ func TestRunnerHelperProcess(t *testing.T) {
os.Exit(0)
case "recover-missing-acp-metadata":
helperRecoverMissingACPMetadata()
case "recover-openclaw-prompt-missing-acp-metadata":
helperRecoverOpenClawPromptMissingACPMetadata()
default:
fmt.Fprintf(os.Stderr, "unknown helper mode %q", mode)
os.Exit(2)
Expand Down Expand Up @@ -185,6 +219,50 @@ func helperRecoverMissingACPMetadata() {
os.Exit(0)
}

func helperRecoverOpenClawPromptMissingACPMetadata() {
statePath := os.Getenv("ACP_METADATA_RECOVERY_STATE")
if statePath == "" {
fmt.Fprintln(os.Stderr, "missing ACP_METADATA_RECOVERY_STATE")
os.Exit(2)
}

args := os.Args
command := ""
for index := 0; index+2 < len(args); index++ {
if args[index] != "openclaw" {
continue
}
switch {
case args[index+1] == "sessions":
command = args[index+2]
case args[index+1] == "--session" || (index+2 < len(args) && args[index+1] == "agent-main-acp-d9d29d47"):
command = "prompt"
default:
command = "prompt"
}
break
}
if command == "" {
fmt.Fprintf(os.Stderr, "missing openclaw command in %q", args)
os.Exit(2)
}

previous, _ := os.ReadFile(statePath)
if err := os.WriteFile(statePath, append(previous, []byte(command+"\n")...), 0o600); err != nil {
fmt.Fprintf(os.Stderr, "write state: %v", err)
os.Exit(2)
}

if command == "prompt" && !strings.Contains(string(previous), "new\n") {
fmt.Fprintln(os.Stderr, "ACP error (ACP_SESSION_INIT_FAILED): ACP metadata is missing for agent:main:acp:d9d29d47-c7c5-40d0-9773-e464d4430352. Recreate this ACP session with /acp spawn and rebind the thread. next: If this session is stale, recreate it with /acp spawn and rebind the thread.")
os.Exit(1)
}
if command == "prompt" {
fmt.Fprintln(os.Stdout, "prompt-ok")
}
os.Exit(0)
}

func helperCommand(t *testing.T, mode string) (string, []string) {
t.Helper()

Expand Down
Loading