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: 2 additions & 0 deletions MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Expected behaviors and their verification status. This is the project's oracle
| Status | af status displays Recent section with exited agents | Manual: `af status` (daemon required) | manual | 2026-02-10 |
| Events | Session ID captured from plugin session.created event | `go test ./internal/daemon/... -run TestClaimSessionPoolAgent` | covered | 2026-02-19 |
| Status | Agent detail shows opencode session ID in TUI meta pane | Manual: TUI agent detail view | manual | 2026-02-10 |
| Control Center | Manual daemon targeting defaults to the single global endpoint without project hashing | `cd macos/ControlCenter && swift test --filter ShellBootstrapTests` | covered | 2026-03-14 |
| Control Center | App-triggered daemon start uses manual mode with an aligned listen address | `cd macos/ControlCenter && swift test --filter DaemonControlTests` | covered | 2026-03-14 |
| Agent Control | af kill <agent> sends SIGTERM and validates agent state | `go test ./internal/daemon/... -run TestHandleAgentKill` | covered | 2026-02-10 |
| Agent Control | Kill rejects invalid PIDs (0 or negative) | `go test ./internal/daemon/... -run TestHandleAgentKillInvalidPID` | covered | 2026-02-10 |
| Agent Control | Kill rejects non-running agents | `go test ./internal/daemon/... -run TestHandleAgentKillNonRunningAgent` | covered | 2026-02-10 |
6 changes: 5 additions & 1 deletion cmd/af/cmd/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ func buildConfig(cmd *cobra.Command) daemon.Config {
if cmd.Flags().Changed("project") {
cfg.Project, _ = cmd.Flags().GetString("project")
}
if cmd.Flags().Changed("listen-addr") {
cfg.ListenAddr, _ = cmd.Flags().GetString("listen-addr")
}
if cmd.Flags().Changed("poll-interval") {
cfg.PollInterval, _ = cmd.Flags().GetDuration("poll-interval")
}
Expand Down Expand Up @@ -114,7 +117,7 @@ func startDetached(cmd *cobra.Command) {

// Forward all flags except --detach.
reArgs := []string{"daemon", "start"}
for _, name := range []string{"project", "poll-interval", "pool-size", "spawn-cmd", "server-url", "spawn-policy", "max-retries", "solo", "config"} {
for _, name := range []string{"project", "listen-addr", "poll-interval", "pool-size", "spawn-cmd", "server-url", "spawn-policy", "max-retries", "solo", "config"} {
if cmd.Flags().Changed(name) {
val, _ := cmd.Flags().GetString(name)
// Duration and int flags also work with GetString via pflag.
Expand Down Expand Up @@ -169,6 +172,7 @@ func init() {
f := daemonStartCmd.Flags()
f.BoolP("detach", "d", false, "Run in background")
f.StringP("project", "p", "", "Project to watch for tasks (required for --spawn-policy=auto)")
f.String("listen-addr", "", "Daemon listen address override (for example 127.0.0.1:7070)")
f.Duration("poll-interval", daemon.DefaultPollInterval, "How often to poll prog for tasks")
f.Int("pool-size", daemon.DefaultPoolSize, "Maximum concurrent agent slots")
f.String("spawn-cmd", daemon.DefaultSpawnCmd, "Command to launch agent sessions")
Expand Down
24 changes: 24 additions & 0 deletions cmd/af/cmd/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package cmd

import (
"bytes"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"
)

func TestPrintDaemonNotRunning(t *testing.T) {
Expand All @@ -18,3 +21,24 @@ func TestPrintDaemonNotRunning(t *testing.T) {
t.Fatalf("output = %q, want start hint", out)
}
}

func TestBuildConfigUsesExplicitListenAddrOverride(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("listen-addr", "", "")
cmd.Flags().String("config", "", "")

if err := cmd.Flags().Set("listen-addr", "127.0.0.1:7099"); err != nil {
t.Fatalf("set listen-addr: %v", err)
}
if err := cmd.Flags().Set("config", filepath.Join(t.TempDir(), "missing.yaml")); err != nil {
t.Fatalf("set config: %v", err)
}

cfg := buildConfig(cmd)
if cfg.ListenAddr != "127.0.0.1:7099" {
t.Fatalf("listen_addr = %q, want 127.0.0.1:7099", cfg.ListenAddr)
}
if cfg.SpawnPolicy != "manual" {
t.Fatalf("spawn_policy = %q, want manual", cfg.SpawnPolicy)
}
}
69 changes: 69 additions & 0 deletions docs/solutions/handoffs/macos-manual-daemon-targeting-20260314.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# macOS Manual Daemon Targeting

**Date**: 2026-03-14
**Task**: ts-2df50e

## Context

This ticket follows `ts-27d3dd`, which made manual daemon addressing global by default in the CLI/backend. The macOS Control Center needed to adopt that contract so a globally installed app can connect to manual mode without knowing a project-derived daemon URL or depending on repo cwd.

## What Was Done

1. Updated bootstrap resolution in `macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift`
- default daemon target is now the global manual endpoint
- explicit loopback overrides still win
- bootstrap now records the daemon target source, reason, and normalized listen address

2. Updated app-triggered start in `macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift`
- app now starts the daemon with `--spawn-policy manual`
- app forwards `--listen-addr` matching the resolved daemon target so start/probe/monitoring stay aligned

3. Added CLI support in `cmd/af/cmd/daemon.go`
- `af daemon start` now accepts `--listen-addr`
- detached re-exec forwards the flag correctly

4. Expanded diagnostics and operator visibility
- transport/monitoring notes explain why the app chose its daemon endpoint
- lifecycle and monitoring notes now call out non-manual daemons and daemon URL mismatches
- diagnostics view renders the daemon target reason directly

5. Added regression coverage
- Swift tests cover global manual defaults, explicit overrides, start invocation, and mismatch diagnostics
- Go test covers `buildConfig` honoring `--listen-addr`

## What Was Tried That Didn't Work

The original app behavior reused project hashing from the CLI’s old auto-oriented path. That was the root of the unreachable-daemon report: the app and daemon could both be “correct” relative to their own targeting rules while still speaking to different endpoints. The fix had to align the start path as well as the read path.

## Key Decisions

- The app now treats manual mode as the default operator contract; project name remains display context, not the default transport key.
- The app intentionally targets only the single global manual daemon by default. Connecting to any other manual daemon requires an explicit loopback override.
- Explicit loopback daemon overrides remain supported, but they are treated as manual endpoint overrides, not as a signal to re-enter project hashing.
- A small CLI addition (`--listen-addr`) was included because it is the simplest way to preserve alignment during app-triggered daemon starts.

## Files Modified

- `macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift`
- `macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift`
- `macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift`
- `macos/ControlCenter/Sources/AetherflowControlCenter/ShellStores.swift`
- `macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift`
- `macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift`
- `macos/ControlCenter/Tests/AetherflowControlCenterTests/ShellBootstrapTests.swift`
- `macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonControlTests.swift`
- `macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonLifecycleStoreTests.swift`
- `macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift`
- `cmd/af/cmd/daemon.go`
- `cmd/af/cmd/daemon_test.go`
- `MATRIX.md`

## Verification

- `swift test`
- `go test ./cmd/af/cmd`
- `go test ./...`
- `go build ./...`
- `go vet ./...`
- `golangci-lint run`
- `git diff --check`
4 changes: 4 additions & 0 deletions docs/solutions/learnings.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### daemon-routing: explicit `--project` is still a transport override

Manual mode can be global by default without making `--project` inert. In this CLI, operators use `--project` on non-start commands as the only ad hoc way to reach a project-scoped daemon. Resolver changes need to preserve that explicit routing path even if the default manual endpoint becomes global.

### macos-control-center: align daemon start with the resolved probe target

Changing the app’s default daemon URL is not enough on its own. If the app can also start the daemon, the start command must carry the same resolved endpoint or the app can still boot one daemon and probe another. Passing the normalized listen address through the start path keeps manual-mode bootstrap coherent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
module: macos-control-center
date: 2026-03-14
problem_type: logic_error
component: daemon-targeting
symptoms:
- "the macOS app reports the daemon as unreachable even though the daemon started successfully"
- "manual-mode monitoring requires operators to know a project-hashed daemon URL ahead of time"
- "app-triggered daemon start can launch a daemon on a different endpoint than the app probes"
root_cause: the app reused project-scoped daemon hashing for manual-mode defaults and did not preserve explicit endpoint overrides during daemon start
resolution_type: code_fix
severity: medium
tags:
- macos
- control-center
- daemon
- manual-mode
- bootstrap
- diagnostics
---

# macOS Manual Daemon Targeting

## Problem

The globally installed Control Center app was deriving its daemon endpoint the same way auto mode does: from repo/project identity. That is the wrong default for manual mode, where the operator should be able to open the app and connect to the single global manual daemon without precomputing a project-specific URL.

This implementation intentionally scopes the app to the single global manual daemon by default. Project-scoped manual daemons are not auto-discovered by the app; they require an explicit loopback override.

## What Didn't Work

The app already had explicit daemon URL overrides, but the default path still hashed the project name. That meant the app could probe one loopback URL while `af daemon start` brought up a daemon on another. Fixing only the probe side would still leave app-triggered start misaligned.

## Solution

1. Make the app default to the global manual daemon URL (`http://127.0.0.1:7070`) unless an explicit loopback override is provided.
2. Track why the daemon target was chosen, and surface that reason in transport and diagnostics views.
3. Start the daemon with `af daemon start --detach --spawn-policy manual --listen-addr <resolved target>` so app-triggered start, lifecycle probing, and monitoring all converge on the same endpoint.
4. Add `--listen-addr` support to `af daemon start` so the macOS app can preserve explicit endpoint overrides without inventing temp config files.

## Prevention

- Treat the macOS app as a global operator tool, not a repo-local shell wrapper.
- Keep manual daemon defaults project-agnostic and global; only explicit endpoint overrides should change the target.
- When the app chooses a daemon endpoint, record both the chosen URL and the reason so mismatch reports are debuggable.
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ private struct SectionPreview: View {
DiagnosticRow(label: "project", value: transport.projectName)
DiagnosticRow(label: "cwd", value: transport.workingDirectory)
DiagnosticRow(label: "daemon_url", value: transport.daemonURL)
DiagnosticRow(label: "daemon_reason", value: transport.daemonTargetReason)
DiagnosticRow(label: "cli", value: transport.cliPath)
DiagnosticRow(label: "lifecycle", value: lifecycle.phase.rawValue.lowercased())
DiagnosticRow(label: "note", value: transport.note)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,17 +518,34 @@ private struct RPCEnvelope<Result: Decodable>: Decodable {
let error: String?
}

private struct CommandOutput: Sendable {
struct CommandOutput: Sendable {
let status: Int32
let stdout: String
let stderr: String
}

struct CommandInvocation: Sendable {
let executable: String
let arguments: [String]
let currentDirectory: String
}

struct DefaultDaemonController: DaemonControlling, Sendable {
private let session: URLSession
private let commandRunner: @Sendable (CommandInvocation) throws -> CommandOutput

init(session: URLSession = DefaultDaemonController.makeSession()) {
init(
session: URLSession = DefaultDaemonController.makeSession(),
commandRunner: @escaping @Sendable (CommandInvocation) throws -> CommandOutput = { invocation in
try DefaultDaemonController.runCommand(
executable: invocation.executable,
arguments: invocation.arguments,
currentDirectory: invocation.currentDirectory
)
}
) {
self.session = session
self.commandRunner = commandRunner
}

func fetchLifecycle(daemonURL: String) async throws -> DaemonLifecyclePayload {
Expand Down Expand Up @@ -593,12 +610,9 @@ struct DefaultDaemonController: DaemonControlling, Sendable {
}

func requestStart(context: ShellBootstrapContext) async throws -> DaemonStartReceipt {
let invocation = Self.startInvocation(for: context)
let output = try await Self.runBlocking {
try Self.runCommand(
executable: context.cliPath,
arguments: ["daemon", "start", "--detach", "--project", context.projectName],
currentDirectory: context.workingDirectory
)
try commandRunner(invocation)
}
guard output.status == 0 else {
throw DaemonControlError.commandFailed(Self.commandFailureMessage(output))
Expand All @@ -607,6 +621,18 @@ struct DefaultDaemonController: DaemonControlling, Sendable {
return DaemonStartReceipt(message: message)
}

static func startInvocation(for context: ShellBootstrapContext) -> CommandInvocation {
var arguments = ["daemon", "start", "--detach", "--spawn-policy", "manual"]
if let listenAddr = context.daemonListenAddressOverride?.nonEmptyTrimmed {
arguments.append(contentsOf: ["--listen-addr", listenAddr])
}
return CommandInvocation(
executable: context.cliPath,
arguments: arguments,
currentDirectory: context.workingDirectory
)
}

private func decodeEnvelope<T: Decodable>(_ data: Data, response: URLResponse) throws -> T {
guard let httpResponse = response as? HTTPURLResponse else {
throw DaemonControlError.invalidResponse("unexpected non-HTTP response")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ struct MonitoringSnapshot: Equatable, Sendable {
queue: [],
selectedWorkloadID: nil,
selectedDetail: nil,
note: "Waiting for the daemon monitoring endpoint to answer.",
note: "\(context.daemonTargetReason) Waiting for the daemon monitoring endpoint to answer.",
lastError: nil,
updatedAt: .now
)
Expand Down Expand Up @@ -490,12 +490,15 @@ final class MonitoringStore: ObservableObject {
}

private func monitoringNote(for status: DaemonStatusPayload, workloadCount: Int) -> String {
var parts = ["Monitoring connected for \(status.project.nonEmptyValue ?? context.projectName)."]
var parts = ["Monitoring connected for \(status.project.nonEmptyValue ?? context.projectName) at \(context.daemonURL)."]
if let poolMode = status.poolMode.nonEmptyValue {
parts.append("Pool mode: \(poolMode).")
}
if let spawnPolicy = status.spawnPolicy.nonEmptyValue {
parts.append("Spawn policy: \(spawnPolicy).")
if let warning = nonManualDaemonWarning(for: spawnPolicy) {
parts.append(warning)
}
}
parts.append("Visible workloads: \(workloadCount).")
return parts.joined(separator: " ")
Expand Down
Loading
Loading