Repository navigation
feat(launcher): background engagement lifecycle — --background, attach, engagements #1049
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
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,102 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config" | ||
| "github.com/PurpleAILAB/Decepticon/clients/launcher/internal/engagement" | ||
| "github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| // resolveEngagementOrList resolves an engagement slug for attach; with an | ||
| // empty slug it lists the available engagements and asks the operator to | ||
| // pick one. | ||
| func resolveEngagementOrList(slug string) (string, error) { | ||
| home := config.DecepticonHome() | ||
| entries, err := engagement.ScanEngagements(home) | ||
| if err != nil { | ||
| return "", fmt.Errorf("scan engagements: %w", err) | ||
| } | ||
| if len(entries) == 0 { | ||
| return "", fmt.Errorf("no engagements yet; run 'decepticon start' to create one") | ||
| } | ||
| if slug != "" { | ||
| for _, entry := range entries { | ||
| if entry.Slug == slug { | ||
| return slug, nil | ||
| } | ||
| } | ||
| return "", fmt.Errorf("unknown engagement %q; available: %s", | ||
| slug, engagementSlugs(entries)) | ||
| } | ||
| return "", fmt.Errorf("choose an engagement: %s", engagementSlugs(entries)) | ||
| } | ||
|
|
||
| func engagementSlugs(entries []engagement.EngagementEntry) string { | ||
| names := make([]string, 0, len(entries)) | ||
| for _, entry := range entries { | ||
| names = append(names, entry.Slug) | ||
| } | ||
| return strings.Join(names, ", ") | ||
| } | ||
|
|
||
| var attachCmd = &cobra.Command{ | ||
| Use: "attach <engagement-name>", | ||
| Short: "Open an existing engagement in the interactive CLI", | ||
| Long: `Start the Decepticon services (if needed) and open the interactive | ||
| conversation CLI attached to an existing engagement workspace. | ||
|
|
||
| This is the counterpart of 'decepticon start --background': background | ||
| engagements are prepared without the CLI and attached here later. Supports | ||
| the same --resume/--continue session selection as 'decepticon start'.`, | ||
| Example: ` decepticon attach my-engagement | ||
| decepticon attach my-engagement --continue | ||
| decepticon attach --resume=THREAD_ID my-engagement`, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| slug := strings.TrimSpace(args[0]) | ||
| if slug == "" { | ||
| return fmt.Errorf("engagement name is required; run 'decepticon engagements' to list") | ||
| } | ||
| if _, err := resolveEngagementOrList(slug); err != nil { | ||
| return err | ||
| } | ||
| ui.Info("Attaching to engagement " + slug + "…") | ||
| return runStartMode(cmd, nil, slug, true) | ||
| }, | ||
| } | ||
|
|
||
| var engagementsCmd = &cobra.Command{ | ||
| Use: "engagements", | ||
| Short: "List existing engagement workspaces", | ||
| Long: `List engagement workspaces under the Decepticon home, with their | ||
| Red-approval state (whether an approved scope is present). Run | ||
| 'decepticon attach <name>' to open one in the interactive CLI.`, | ||
| Example: " decepticon engagements\n decepticon attach my-engagement", | ||
| Args: cobra.NoArgs, | ||
| RunE: func(cmd *cobra.Command, _ []string) error { | ||
| entries, err := engagement.ScanEngagements(config.DecepticonHome()) | ||
| if err != nil { | ||
| return fmt.Errorf("scan engagements: %w", err) | ||
| } | ||
| if len(entries) == 0 { | ||
| ui.DimText("No engagements yet — run 'decepticon start' to create one.") | ||
| return nil | ||
| } | ||
| for _, entry := range entries { | ||
| state := "not approved" | ||
| if entry.Ready { | ||
| state = "red approved" | ||
| } | ||
| cmd.Printf(" %-40s %s\n", entry.Slug, state) | ||
| } | ||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| func init() { | ||
| rootCmd.AddCommand(attachCmd) | ||
| rootCmd.AddCommand(engagementsCmd) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,7 @@ var ( | |
| skipUpdate bool | ||
| resumeThread string | ||
| continueThread bool | ||
| backgroundRun bool | ||
| ) | ||
|
|
||
| var startCmd = &cobra.Command{ | ||
|
|
@@ -98,16 +99,23 @@ func init() { | |
| rootCmd.MarkFlagsMutuallyExclusive("resume", "continue") | ||
| startCmd.MarkFlagsMutuallyExclusive("resume", "continue") | ||
|
|
||
| startCmd.Flags().BoolVar(&backgroundRun, "background", false, | ||
| "Start services and prepare the engagement without opening the interactive CLI; attach later with 'decepticon attach <name>'") | ||
|
Comment on lines
+102
to
+103
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.
This adds the user-visible AGENTS.md reference: AGENTS.md:L3-L8 Useful? React with 👍 / 👎. |
||
|
|
||
| rootCmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| return runStart(cmd, args) | ||
| } | ||
| } | ||
|
|
||
| func runStart(cmd *cobra.Command, args []string) error { | ||
| return runStartMode(cmd, args, "") | ||
| return runStartMode(cmd, args, "", false) | ||
| } | ||
|
|
||
| func runStartMode(cmd *cobra.Command, args []string, existingEngagement string) error { | ||
| // runStartMode boots the stack for one of three callers: plain `start` | ||
| // (existingEngagement == ""), the desktop agent service (`serve`, which | ||
| // returns once services are ready), and `attach` (attachInteractive, which | ||
| // drops the operator into the interactive CLI on an existing engagement). | ||
| func runStartMode(cmd *cobra.Command, args []string, existingEngagement string, attachInteractive bool) error { | ||
| if cmd == rootCmd { | ||
| if err := validateRootArgs(args); err != nil { | ||
| return err | ||
|
|
@@ -391,7 +399,10 @@ func runStartMode(cmd *cobra.Command, args []string, existingEngagement string) | |
| return fmt.Errorf("service readiness: %w; inspect `decepticon doctor` and `decepticon logs` (services kept)", err) | ||
| } | ||
|
|
||
| if existingEngagement != "" { | ||
| // The desktop agent service ends here — it serves host clients and has | ||
| // no terminal. Attach keeps going: its whole point is the interactive | ||
| // CLI on the existing engagement. | ||
| if existingEngagement != "" && !attachInteractive { | ||
| ui.Success("Desktop agent service ready for " + choice.Engagement) | ||
| fmt.Println("DECEPTICON_DESKTOP_ASSISTANT=" + choice.AssistantID) | ||
| return nil | ||
|
|
@@ -423,6 +434,14 @@ func runStartMode(cmd *cobra.Command, args []string, existingEngagement string) | |
| cliEnv["WEB_PORT"] = port | ||
| } | ||
|
|
||
| // Background mode: services are up and the engagement workspace is bound; | ||
| // skip the interactive CLI and let the operator attach later. | ||
| if backgroundRun { | ||
| ui.Success("Engagement " + choice.Engagement + " ready in the background (thread: " + threadOrPickerHint(thread) + ").") | ||
| ui.DimText("Attach with 'decepticon attach " + choice.Engagement + "'. Services kept running.") | ||
| return nil | ||
| } | ||
|
|
||
| // Pass through terminal. Services are intentionally left running on CLI exit | ||
| // so re-entry is fast (cold start is ~75s); use 'decepticon stop' to shut | ||
| // the stack down. | ||
|
|
@@ -588,3 +607,10 @@ func candidateProbeURLs(raw string) []string { | |
| add("127.0.0.1") | ||
| return candidates | ||
| } | ||
|
|
||
| func threadOrPickerHint(thread string) string { | ||
| if thread != "" && thread != "picker" { | ||
| return thread | ||
| } | ||
| return "created on first attach" | ||
| } | ||
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.
When an operator runs
decepticon attachwithout a name, Cobra rejects the invocation beforeRunE, so the advertised no-argument listing behavior and the empty-slug branch ofresolveEngagementOrListare unreachable; the user only receives an argument-count error. Accept zero or one argument and route the zero-argument case to the engagement listing.Useful? React with 👍 / 👎.