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
8 changes: 4 additions & 4 deletions MIGRATIONTODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
- [x] 1.2 Full README rewrite — reframed as DutyBound, "Why DutyBound?" section, updated architecture diagram, process enforcement, quick start, stack, naming hierarchy
- [x] ~~1.3 Update agent personas~~ — **SKIPPED** (low priority for now)
- [x] 1.4 Remove `--force` from Kai — added explicit ban to openClawPrompt and .mission/CLAUDE.md
- [ ] 1.5 Fix broken dashboard views — agent views, token usage display, audit filters
- [x] 1.5 Fix broken dashboard views — fixed: "done" vs "complete" status mismatch across all components, token WS handler now uses adaptTokens() instead of shallow merge, audit filters derived from actual data

## Stream 2: On-Demand Container

- [ ] 2.1 Wake-on-request endpoint — hit URL, container starts, Kai available
- [ ] 2.2 Auto-shutdown on idle — container stops after N minutes of inactivity
- [ ] 2.3 Health/status page — loading state during cold start, then transitions to chat
- [x] 2.1 Wake-on-request endpoint — `mc launcher` with `/api/wake` + auto-wake on any request; `mc wake` CLI command
- [x] 2.2 Auto-shutdown on idle — launcher stops both services after `--idle-timeout` (default 30m)
- [x] 2.3 Health/status page — `dutybound-client.tsx` polls `/api/health`, shows loading state, renders KaiClient when ready

## Stream 3: Kai Setup Wizard

Expand Down
6 changes: 6 additions & 0 deletions cmd/mc/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
initGit bool
initOpenClaw bool
initConfig string
initAutoMode bool
)

func init() {
Expand All @@ -27,6 +28,7 @@ func init() {
initCmd.Flags().BoolVar(&initGit, "git", false, "Initialize git repository")
initCmd.Flags().BoolVar(&initOpenClaw, "openclaw", true, "Enable OpenClaw mode")
initCmd.Flags().StringVar(&initConfig, "config", "", "Path to JSON config file with workflow matrix")
initCmd.Flags().BoolVar(&initAutoMode, "auto-mode", false, "Enable automatic gate approval")
}

var initCmd = &cobra.Command{
Expand Down Expand Up @@ -139,6 +141,10 @@ func runInit(cmd *cobra.Command, args []string) error {
OpenClaw: initOpenClaw,
}

if initAutoMode {
config.AutoMode = true
}

// If matrix provided, include it in config
if matrix, ok := matrixConfig["matrix"]; ok {
config.Matrix = matrix
Expand Down
299 changes: 299 additions & 0 deletions cmd/mc/launcher.go
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")
}
Comment on lines +230 to +260
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd . && find . -name "launcher.go" -path "*/cmd/mc/*" | head -1

Repository: DarlingtonDeveloper/MissionControl

Length of output: 98


🏁 Script executed:

cat -n cmd/mc/launcher.go | head -300

Repository: DarlingtonDeveloper/MissionControl

Length of output: 9312


🏁 Script executed:

gofmt -l cmd/mc/launcher.go

Repository: DarlingtonDeveloper/MissionControl

Length of output: 60


🏁 Script executed:

gofmt -d cmd/mc/launcher.go | head -50

Repository: 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 = nil

At 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.


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")
}
18 changes: 18 additions & 0 deletions cmd/mc/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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).


## Important

- Always check mc status before making decisions
Expand Down
Loading
Loading