-
Notifications
You must be signed in to change notification settings - Fork 0
feat: DutyBound hibernation — branding, stabilisation, on-demand launcher #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ee42174
c47f5ef
299bb56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,299 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "log" | ||
| "net" | ||
| "net/http" | ||
| "net/http/httputil" | ||
| "net/url" | ||
| "os" | ||
| "os/exec" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| type launcherState int | ||
|
|
||
| const ( | ||
| stateSleeping launcherState = iota | ||
| stateStarting | ||
| stateReady | ||
| ) | ||
|
|
||
| func (s launcherState) String() string { | ||
| switch s { | ||
| case stateSleeping: | ||
| return "sleeping" | ||
| case stateStarting: | ||
| return "starting" | ||
| case stateReady: | ||
| return "ready" | ||
| default: | ||
| return "unknown" | ||
| } | ||
| } | ||
|
|
||
| type launcher struct { | ||
| mu sync.Mutex | ||
| state launcherState | ||
| port int | ||
| backendPort int | ||
| idleTimeout time.Duration | ||
|
|
||
| idleTimer *time.Timer | ||
| serveCmd *exec.Cmd | ||
| proxy *httputil.ReverseProxy | ||
| } | ||
|
|
||
| func newLauncher(port, backendPort int, idleTimeout time.Duration) *launcher { | ||
| target, _ := url.Parse(fmt.Sprintf("http://localhost:%d", backendPort)) | ||
| return &launcher{ | ||
| state: stateSleeping, | ||
| port: port, | ||
| backendPort: backendPort, | ||
| idleTimeout: idleTimeout, | ||
| proxy: httputil.NewSingleHostReverseProxy(target), | ||
| } | ||
| } | ||
|
|
||
| func (l *launcher) getState() launcherState { | ||
| l.mu.Lock() | ||
| defer l.mu.Unlock() | ||
| return l.state | ||
| } | ||
|
|
||
| func (l *launcher) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| // Health endpoint always responds immediately | ||
| if r.URL.Path == "/api/health" { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(map[string]string{"status": l.getState().String()}) | ||
| return | ||
| } | ||
|
|
||
| // Wake endpoint | ||
| if r.URL.Path == "/api/wake" && r.Method == http.MethodPost { | ||
| l.triggerWake() | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(map[string]string{"status": l.getState().String()}) | ||
| return | ||
| } | ||
|
|
||
| state := l.getState() | ||
|
|
||
| switch state { | ||
| case stateSleeping: | ||
| l.triggerWake() | ||
| w.Header().Set("Retry-After", "5") | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(503) | ||
| json.NewEncoder(w).Encode(map[string]string{ | ||
| "status": "starting", | ||
| "message": "Services are waking up, please retry shortly", | ||
| }) | ||
|
|
||
| case stateStarting: | ||
| w.Header().Set("Retry-After", "3") | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(503) | ||
| json.NewEncoder(w).Encode(map[string]string{ | ||
| "status": "starting", | ||
| "message": "Services are starting, please retry shortly", | ||
| }) | ||
|
|
||
| case stateReady: | ||
| l.resetIdleTimer() | ||
| l.proxy.ServeHTTP(w, r) | ||
| } | ||
| } | ||
|
|
||
| func (l *launcher) triggerWake() { | ||
| l.mu.Lock() | ||
| if l.state != stateSleeping { | ||
| l.mu.Unlock() | ||
| return | ||
| } | ||
| l.state = stateStarting | ||
| l.mu.Unlock() | ||
|
|
||
| log.Println("launcher: waking up — starting services") | ||
| go l.startServices() | ||
| } | ||
|
|
||
| func (l *launcher) startServices() { | ||
| // Find the mc binary path (use our own executable) | ||
| mcBin, err := os.Executable() | ||
| if err != nil { | ||
| log.Printf("launcher: could not resolve own executable: %v", err) | ||
| l.mu.Lock() | ||
| l.state = stateSleeping | ||
| l.mu.Unlock() | ||
| return | ||
| } | ||
|
|
||
| // Start mc serve as child process | ||
| log.Printf("launcher: starting mc serve --port %d", l.backendPort) | ||
| cmd := exec.Command(mcBin, "serve", "--port", fmt.Sprintf("%d", l.backendPort)) | ||
| cmd.Stdout = os.Stdout | ||
| cmd.Stderr = os.Stderr | ||
| if err := cmd.Start(); err != nil { | ||
| log.Printf("launcher: failed to start mc serve: %v", err) | ||
| l.mu.Lock() | ||
| l.state = stateSleeping | ||
| l.mu.Unlock() | ||
| return | ||
| } | ||
|
|
||
| l.mu.Lock() | ||
| l.serveCmd = cmd | ||
| l.mu.Unlock() | ||
|
|
||
| // Wait for child process in background (detect unexpected exits) | ||
| go func() { | ||
| if err := cmd.Wait(); err != nil { | ||
| log.Printf("launcher: mc serve exited: %v", err) | ||
| } else { | ||
| log.Println("launcher: mc serve exited cleanly") | ||
| } | ||
| // If we're still in ready state, transition back to sleeping | ||
| l.mu.Lock() | ||
| if l.state == stateReady { | ||
| log.Println("launcher: mc serve died unexpectedly, returning to sleeping") | ||
| l.state = stateSleeping | ||
| l.serveCmd = nil | ||
| if l.idleTimer != nil { | ||
| l.idleTimer.Stop() | ||
| } | ||
| } | ||
| l.mu.Unlock() | ||
| }() | ||
|
|
||
| // Poll until both services are healthy | ||
| if err := l.waitForHealthy(); err != nil { | ||
| log.Printf("launcher: health check failed: %v", err) | ||
| l.stopServices() | ||
| return | ||
| } | ||
|
|
||
| l.mu.Lock() | ||
| l.state = stateReady | ||
| l.mu.Unlock() | ||
| log.Println("launcher: services ready") | ||
|
|
||
| l.resetIdleTimer() | ||
| } | ||
|
|
||
| func (l *launcher) waitForHealthy() error { | ||
| ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | ||
| defer cancel() | ||
|
|
||
| mcURL := fmt.Sprintf("http://localhost:%d/api/health", l.backendPort) | ||
| client := &http.Client{Timeout: 2 * time.Second} | ||
|
|
||
| for { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return fmt.Errorf("timed out waiting for mc serve to become healthy") | ||
| default: | ||
| } | ||
|
|
||
| resp, err := client.Get(mcURL) | ||
| if err == nil { | ||
| resp.Body.Close() | ||
| if resp.StatusCode == 200 { | ||
| log.Println("launcher: mc serve is healthy") | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| time.Sleep(500 * time.Millisecond) | ||
| } | ||
| } | ||
|
|
||
| func (l *launcher) resetIdleTimer() { | ||
| l.mu.Lock() | ||
| defer l.mu.Unlock() | ||
|
|
||
| if l.idleTimer != nil { | ||
| l.idleTimer.Stop() | ||
| } | ||
| l.idleTimer = time.AfterFunc(l.idleTimeout, func() { | ||
| log.Printf("launcher: idle for %s — shutting down services", l.idleTimeout) | ||
| l.stopServices() | ||
| }) | ||
| } | ||
|
|
||
| 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") | ||
| } | ||
|
|
||
| var launcherCmd = &cobra.Command{ | ||
| Use: "launcher", | ||
| Short: "Lightweight launcher proxy with lifecycle management for DutyBound services", | ||
| Long: `Runs a lightweight reverse proxy on the configured port. When idle, the | ||
| MissionControl orchestrator is stopped. Incoming requests trigger a wake | ||
| cycle, and the orchestrator is shut down again after the idle timeout. | ||
| OpenClaw Gateway is expected to be managed independently.`, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| port, _ := cmd.Flags().GetInt("port") | ||
| backendPort, _ := cmd.Flags().GetInt("backend-port") | ||
| idleStr, _ := cmd.Flags().GetString("idle-timeout") | ||
|
|
||
| idleTimeout, err := time.ParseDuration(idleStr) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid idle-timeout: %w", err) | ||
| } | ||
|
|
||
| l := newLauncher(port, backendPort, idleTimeout) | ||
|
|
||
| addr := fmt.Sprintf(":%d", port) | ||
| ln, err := net.Listen("tcp", addr) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to listen on %s: %w", addr, err) | ||
| } | ||
|
|
||
| log.Printf("launcher: listening on %s (backend :%d, idle timeout %s)", addr, backendPort, idleTimeout) | ||
| log.Printf("launcher: state = sleeping") | ||
|
|
||
| return http.Serve(ln, l) | ||
| }, | ||
| } | ||
|
|
||
| func init() { | ||
| rootCmd.AddCommand(launcherCmd) | ||
| launcherCmd.Flags().Int("port", 8080, "Launcher listen port") | ||
| launcherCmd.Flags().Int("backend-port", 8081, "MC orchestrator backend port") | ||
| launcherCmd.Flags().String("idle-timeout", "30m", "Shutdown after idle duration") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -126,6 +126,24 @@ cat .mission/findings/<task-id>.json | |
|
|
||
| Synthesize findings and update specs or create new tasks as needed. | ||
|
|
||
| ## 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. | ||
|
Comment on lines
+129
to
+145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Align onboarding wording with the OpenClaw persona and gate-approval policy. ✏️ 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 |
||
|
|
||
| ## Important | ||
|
|
||
| - Always check mc status before making decisions | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: DarlingtonDeveloper/MissionControl
Length of output: 98
🏁 Script executed:
cat -n cmd/mc/launcher.go | head -300Repository: DarlingtonDeveloper/MissionControl
Length of output: 9312
🏁 Script executed:
Repository: 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 callscmd.Process.Wait()even thoughcmd.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 fromcmd.Process.Kill().Use a single wait path via a stored channel instead of calling
Process.Wait()twice, and always check errors fromSignal()andKill():Suggested fix
Add
waitCh chan errorfield to thelauncherstruct, modify the wait goroutine at line 156 to send the error towaitCh, and updatestopServices()to receive fromwaitChwith a timeout instead of callingProcess.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:At lines 236–237, save the wait channel:
At lines 244–256, use the channel and check errors:
Or simpler, use the wait channel directly with timeout:
🧰 Tools
🪛 GitHub Actions: CI
[error] 244-244: Error return value of
cmd.Process.Signalis not checked (errcheck)🪛 GitHub Check: build-and-test
[failure] 248-248:
Error return value of
cmd.Process.Waitis not checked (errcheck)[failure] 244-244:
Error return value of
cmd.Process.Signalis not checked (errcheck)🤖 Prompt for AI Agents