feat: DutyBound hibernation — branding, stabilisation, on-demand launcher#55
Conversation
📝 WalkthroughWalkthroughAdds a wake-on-request launcher and CLI, a new AutoMode config flag and onboarding prompt text. The launcher proxies to a child MC serve process, exposes /api/wake and /api/health, auto-starts on demand, and auto-shuts down after an idle timeout. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client as Client\n(requestor)
participant Launcher as Launcher\n(proxy + lifecycle)
participant Child as MC Serve\n(child process)
participant Backend as Backend API\n(local HTTP)
rect rgba(200,220,255,0.5)
Client->>Launcher: HTTP request (any)
alt Launcher sleeping
Client->>Launcher: GET /api/health or POST /api/wake
Launcher-->>Client: 503 + Retry-After (triggers wake)
Launcher->>Launcher: triggerWake (async)
Launcher->>Child: start mc serve (fork child process)
Child->>Backend: bind and serve on backend port
Launcher->>Backend: poll /api/health until healthy
Backend-->>Launcher: status: ready
Launcher->>Launcher: transition to ready, start idle timer
Client->>Launcher: retry request
Launcher->>Backend: reverse-proxy request
Backend-->>Client: proxied response
else Launcher ready
Client->>Launcher: request
Launcher->>Backend: reverse-proxy request
Backend-->>Client: response
end
end
rect rgba(255,220,200,0.5)
note right of Launcher: idle timer expires → stopServices\n(signal child, wait, kill if needed)\ntransition to sleeping
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
No actionable comments were generated in the recent review. 🎉 🧹 Recent nitpick comments
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@cmd/mc/launcher.go`:
- Around line 230-260: The stopServices method currently calls Process.Wait a
second time and ignores errors from Signal/Kill; add a waitCh chan error to the
launcher struct, have the goroutine that runs cmd.Wait() (the one started when
serveCmd is launched) send its error into waitCh, then in stopServices() read
from waitCh with a 5s timeout instead of calling cmd.Process.Wait() again; also
capture and handle errors returned by cmd.Process.Signal(...) and
cmd.Process.Kill(...) (logging or returning them as appropriate) and ensure you
still nil out serveCmd and stop idleTimer and set stateSleeping as done now.
In `@cmd/mc/wake.go`:
- Around line 21-47: The POST and GET responses in the wake flow currently only
check transport errors and ignore non-2xx status codes and JSON decode errors;
update the client.Post call in the wake command to check resp.StatusCode (treat
any non-2xx as an error), defer resp.Body.Close() immediately after ensuring
resp != nil, and do the same for client.Get in the polling loop (defer or ensure
close on every loop iteration). After client.Get, validate resp.StatusCode
before decoding, then capture the error returned by
json.NewDecoder(resp.Body).Decode(&health) and treat decode errors as failures
(log/continue or return an error instead of silently continuing). Reference:
baseURL, client.Post, client.Get, the local health struct and
json.NewDecoder(...).Decode to locate where to add status-code checks, proper
resp.Body.Close() defers, and decode error handling.
| func (l *launcher) stopServices() { | ||
| l.mu.Lock() | ||
| if l.idleTimer != nil { | ||
| l.idleTimer.Stop() | ||
| l.idleTimer = nil | ||
| } | ||
| cmd := l.serveCmd | ||
| l.serveCmd = nil | ||
| l.state = stateSleeping | ||
| l.mu.Unlock() | ||
|
|
||
| // Kill mc serve child process | ||
| if cmd != nil && cmd.Process != nil { | ||
| log.Println("launcher: stopping mc serve") | ||
| cmd.Process.Signal(os.Interrupt) | ||
| // Give it a moment to shut down gracefully | ||
| done := make(chan struct{}) | ||
| go func() { | ||
| cmd.Process.Wait() | ||
| close(done) | ||
| }() | ||
| select { | ||
| case <-done: | ||
| case <-time.After(5 * time.Second): | ||
| log.Println("launcher: mc serve did not exit, killing") | ||
| cmd.Process.Kill() | ||
| } | ||
| } | ||
|
|
||
| log.Println("launcher: services stopped, sleeping") | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd . && find . -name "launcher.go" -path "*/cmd/mc/*" | head -1Repository: DarlingtonDeveloper/MissionControl
Length of output: 98
🏁 Script executed:
cat -n cmd/mc/launcher.go | head -300Repository: DarlingtonDeveloper/MissionControl
Length of output: 9312
🏁 Script executed:
gofmt -l cmd/mc/launcher.goRepository: DarlingtonDeveloper/MissionControl
Length of output: 60
🏁 Script executed:
gofmt -d cmd/mc/launcher.go | head -50Repository: DarlingtonDeveloper/MissionControl
Length of output: 60
Fix double-wait on child process and add error checking.
Line 244 ignores the error from cmd.Process.Signal, and line 248 calls cmd.Process.Wait() even though cmd.Wait() is already running in a goroutine at line 156. In Go, Process.Wait() can only be called once; the second call will fail with "already waited" and skip the kill path, causing errcheck CI failures. Additionally, line 255 ignores the error from cmd.Process.Kill().
Use a single wait path via a stored channel instead of calling Process.Wait() twice, and always check errors from Signal() and Kill():
Suggested fix
Add waitCh chan error field to the launcher struct, modify the wait goroutine at line 156 to send the error to waitCh, and update stopServices() to receive from waitCh with a timeout instead of calling Process.Wait() again. Check all signal/kill errors:
type launcher struct {
mu sync.Mutex
state launcherState
port int
backendPort int
idleTimeout time.Duration
idleTimer *time.Timer
serveCmd *exec.Cmd
+ waitCh chan error
proxy *httputil.ReverseProxy
}At line 156, wrap cmd.Wait() and send result to channel:
- go func() {
- if err := cmd.Wait(); err != nil {
+ waitCh := make(chan error, 1)
+ go func() {
+ err := cmd.Wait()
+ waitCh <- err
+ close(waitCh)
+ if err != nil {At lines 236–237, save the wait channel:
cmd := l.serveCmd
+ waitCh := l.waitCh
l.serveCmd = nil
+ l.waitCh = nilAt lines 244–256, use the channel and check errors:
- cmd.Process.Signal(os.Interrupt)
+ if err := cmd.Process.Signal(os.Interrupt); err != nil {
+ log.Printf("launcher: failed to signal mc serve: %v", err)
+ }
done := make(chan struct{})
go func() {
- cmd.Process.Wait()
- close(done)
+ if waitCh != nil {
+ <-waitCh
+ }
+ close(done)Or simpler, use the wait channel directly with timeout:
- done := make(chan struct{})
- go func() {
- cmd.Process.Wait()
- close(done)
- }()
- select {
- case <-done:
+ if waitCh != nil {
+ select {
+ case <-waitCh:
+ case <-time.After(5 * time.Second):
+ log.Println("launcher: mc serve did not exit, killing")
+ if err := cmd.Process.Kill(); err != nil {
+ log.Printf("launcher: failed to kill mc serve: %v", err)
+ }
+ }
}🧰 Tools
🪛 GitHub Actions: CI
[error] 244-244: Error return value of cmd.Process.Signal is not checked (errcheck)
🪛 GitHub Check: build-and-test
[failure] 248-248:
Error return value of cmd.Process.Wait is not checked (errcheck)
[failure] 244-244:
Error return value of cmd.Process.Signal is not checked (errcheck)
🤖 Prompt for AI Agents
In `@cmd/mc/launcher.go` around lines 230 - 260, The stopServices method currently
calls Process.Wait a second time and ignores errors from Signal/Kill; add a
waitCh chan error to the launcher struct, have the goroutine that runs
cmd.Wait() (the one started when serveCmd is launched) send its error into
waitCh, then in stopServices() read from waitCh with a 5s timeout instead of
calling cmd.Process.Wait() again; also capture and handle errors returned by
cmd.Process.Signal(...) and cmd.Process.Kill(...) (logging or returning them as
appropriate) and ensure you still nil out serveCmd and stop idleTimer and set
stateSleeping as done now.
| // Send wake request | ||
| fmt.Println("Waking DutyBound services...") | ||
| resp, err := client.Post(baseURL+"/api/wake", "application/json", nil) | ||
| if err != nil { | ||
| return fmt.Errorf("could not reach launcher at %s: %w", baseURL, err) | ||
| } | ||
| resp.Body.Close() | ||
|
|
||
| // Poll until ready | ||
| timeout := time.After(90 * time.Second) | ||
| ticker := time.NewTicker(2 * time.Second) | ||
| defer ticker.Stop() | ||
|
|
||
| for { | ||
| select { | ||
| case <-timeout: | ||
| return fmt.Errorf("timed out waiting for services to become ready") | ||
| case <-ticker.C: | ||
| resp, err := client.Get(baseURL + "/api/health") | ||
| if err != nil { | ||
| continue | ||
| } | ||
| var health struct { | ||
| Status string `json:"status"` | ||
| } | ||
| json.NewDecoder(resp.Body).Decode(&health) | ||
| resp.Body.Close() |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd /tmp && find . -name "wake.go" -type f 2>/dev/null | head -5Repository: DarlingtonDeveloper/MissionControl
Length of output: 60
🏁 Script executed:
cat -n cmd/mc/wake.go | sed -n '21,47p'Repository: DarlingtonDeveloper/MissionControl
Length of output: 1013
🏁 Script executed:
ls -la cmd/mc/wake.go 2>/dev/null || echo "File not found in expected location"
git ls-files | grep -i wake.goRepository: DarlingtonDeveloper/MissionControl
Length of output: 156
🏁 Script executed:
gofmt -l cmd/mc/wake.goRepository: DarlingtonDeveloper/MissionControl
Length of output: 60
🏁 Script executed:
cat -n cmd/mc/wake.goRepository: DarlingtonDeveloper/MissionControl
Length of output: 2336
Handle HTTP response status codes and JSON decode errors to avoid silent timeouts.
Line 23 doesn't check the POST response status code, and Line 39 doesn't check the GET response status code. Both requests only handle network errors, allowing 4xx/5xx responses to proceed as if successful. Additionally, Line 46 ignores JSON decode errors—if the response body is malformed, health.Status silently remains empty and polling continues until timeout with no actionable feedback.
Check both response status codes (treat non-2xx as errors), and validate the JSON decode result before proceeding. Use defer for resp.Body.Close() to ensure cleanup even on errors.
🔧 Suggested fix
// Send wake request
fmt.Println("Waking DutyBound services...")
resp, err := client.Post(baseURL+"/api/wake", "application/json", nil)
if err != nil {
return fmt.Errorf("could not reach launcher at %s: %w", baseURL, err)
}
+ if resp.StatusCode/100 != 2 {
+ resp.Body.Close()
+ return fmt.Errorf("wake request failed: %s", resp.Status)
+ }
resp.Body.Close()
@@
case <-ticker.C:
resp, err := client.Get(baseURL + "/api/health")
if err != nil {
continue
}
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ continue
+ }
var health struct {
Status string `json:"status"`
}
- json.NewDecoder(resp.Body).Decode(&health)
+ if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
+ resp.Body.Close()
+ continue
+ }
resp.Body.Close()🤖 Prompt for AI Agents
In `@cmd/mc/wake.go` around lines 21 - 47, The POST and GET responses in the wake
flow currently only check transport errors and ignore non-2xx status codes and
JSON decode errors; update the client.Post call in the wake command to check
resp.StatusCode (treat any non-2xx as an error), defer resp.Body.Close()
immediately after ensuring resp != nil, and do the same for client.Get in the
polling loop (defer or ensure close on every loop iteration). After client.Get,
validate resp.StatusCode before decoding, then capture the error returned by
json.NewDecoder(resp.Body).Decode(&health) and treat decode errors as failures
(log/continue or return an error instead of silently continuing). Reference:
baseURL, client.Post, client.Get, the local health struct and
json.NewDecoder(...).Decode to locate where to add status-code checks, proper
resp.Body.Close() defers, and decode error handling.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
cmd/mc/init.go (1)
53-56:⚠️ Potential issue | 🟡 MinorIgnored error from
os.UserHomeDir()can produce malformed paths.If
UserHomeDirfails (e.g.,$HOMEunset),homeis empty and the resulting path becomes invalid (e.g.,/rest/of/path). Consider returning an error or logging a warning.Suggested fix
if strings.HasPrefix(workDir, "~") { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to expand ~: %w", err) + } workDir = filepath.Join(home, workDir[1:]) }cmd/mc/prompts.go (1)
154-176:⚠️ Potential issue | 🟠 MajorHeredoc quoting prevents timestamp expansion.
The single-quoted delimiter
<< 'EXCHANGE'disables command substitution, so$(date ...)will be written literally instead of generating an actual timestamp. This breaks the intended ISO 8601 timestamp requirement.Suggested fix — compute date before heredoc
` + "```" + `bash +TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) -cat >> .mission/conversation.md << 'EXCHANGE' +cat >> .mission/conversation.md << EXCHANGE -## Assistant [$(date -u +%Y-%m-%dT%H:%M:%SZ)] +## Assistant [$TIMESTAMP] <your complete response here> ---END--- EXCHANGE ` + "```" + `Note: Using an unquoted delimiter means any
$or backticks in the response content could be interpreted by the shell. If responses may contain such characters, consider escaping them or using a different approach (e.g.,printforechowith proper quoting).
🧹 Nitpick comments (1)
cmd/mc/init.go (1)
200-203: Audit log omits the newauto_modesetting.For traceability, consider including
initAutoModein the audit payload alongsideopenclaw.Suggested fix
writeAuditLog(missionDir, AuditProjectInitialized, "cli", map[string]interface{}{ "path": workDir, "openclaw": initOpenClaw, + "auto_mode": initAutoMode, })
Implements Stream 2 of the hibernation plan: - `mc launcher` — lightweight reverse proxy that starts/stops mc serve on demand - `mc wake` — CLI command to wake services via the launcher - Auto-shutdown after configurable idle timeout (default 30m) - Health endpoint for frontend polling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add conversational onboarding section to OpenClaw prompt so Kai guides new users through project setup (clone, init, gate preferences). Add --auto-mode flag to mc init for automatic gate approval. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ab43720 to
c47f5ef
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@cmd/mc/prompts.go`:
- Around line 129-145: Update the "Onboarding Mode" prompt text to align with
the OpenClaw persona and the gate-approval policy by removing or clarifying the
hard name "you're Kai" (e.g., call out it's an optional alias or use "OpenClaw /
your coordinator") and change the `--auto-mode` mention to require explicit
opt-in phrasing (e.g., "enable auto-mode only if user explicitly agrees").
Ensure the gate approval question explicitly states that automatic approvals are
disabled by default and that the user must opt in, and keep the conversational
tone and step sequence intact (reference the "Onboarding Mode" section, the
`--auto-mode` flag, and the gate approval wording).
| ## Onboarding Mode | ||
|
|
||
| When a user first connects and no project is active (mc status shows no .mission/), | ||
| guide them through setup naturally: | ||
|
|
||
| 1. Greet them — you're Kai, their development coordinator | ||
| 2. Ask what they'd like to build, or if they have an existing repo | ||
| 3. Existing repo: clone it with ` + "`" + `git clone <url> /workspace/<name>` + "`" + ` | ||
| 4. New project: create dir, optionally ` + "`" + `git init` + "`" + ` | ||
| 5. Bootstrap: ` + "`" + `mc init --path <project-path> [--auto-mode]` + "`" + ` | ||
| 6. Ask preferences: | ||
| - Gate approval: "Should I handle gates automatically, or do you want to approve each?" | ||
| - Zones: "What areas — frontend, backend, database, infra?" | ||
| 7. Register: ` + "`" + `mc project register <name> <path>` + "`" + ` | ||
| 8. Confirm setup, suggest starting Discovery stage | ||
|
|
||
| Keep it conversational — you're a colleague, not a rigid wizard. |
There was a problem hiding this comment.
Align onboarding wording with the OpenClaw persona and gate-approval policy.
Line 134 says “you’re Kai” and Line 140 suggests --auto-mode, but earlier the prompt defines OpenClaw and the “Important” section says “don’t auto-approve.” This creates mixed guidance that can steer the coordinator inconsistently. Consider clarifying that “Kai” is an alias (if intended) and that auto-mode is only enabled after explicit opt‑in.
✏️ Suggested wording tweak (within this segment)
-1. Greet them — you're Kai, their development coordinator
+1. Greet them — you're OpenClaw (Kai), their development coordinator
...
-6. Ask preferences:
- - Gate approval: "Should I handle gates automatically, or do you want to approve each?"
+6. Ask preferences:
+ - Gate approval: "Do you want to opt into auto-mode, or approve each gate manually?"🤖 Prompt for AI Agents
In `@cmd/mc/prompts.go` around lines 129 - 145, Update the "Onboarding Mode"
prompt text to align with the OpenClaw persona and the gate-approval policy by
removing or clarifying the hard name "you're Kai" (e.g., call out it's an
optional alias or use "OpenClaw / your coordinator") and change the
`--auto-mode` mention to require explicit opt-in phrasing (e.g., "enable
auto-mode only if user explicitly agrees"). Ensure the gate approval question
explicitly states that automatic approvals are disabled by default and that the
user must opt in, and keep the conversational tone and step sequence intact
(reference the "Onboarding Mode" section, the `--auto-mode` flag, and the gate
approval wording).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
/dutyboundroute added--validate-onlyexits cleanly without.mission/), full README rewrite,--forcebanned from Kai's commands, dashboard status/token/audit fixesmc launcherreverse proxy with wake-on-request, auto-shutdown after idle timeout,mc wakeCLI command, migration TODOs updatedTest plan
make buildsucceeds with new launcher/wake commandsmc launcher --port 8080 --backend-port 8081starts and responds to/api/healthmc waketriggers startup and polls until ready🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features
Documentation