Skip to content

feat: add first-class host UX for plan management (#3844) - #3853

Merged
dgageot merged 9 commits into
mainfrom
feat/host-plan-management-3844
Jul 29, 2026
Merged

feat: add first-class host UX for plan management (#3844)#3853
dgageot merged 9 commits into
mainfrom
feat/host-plan-management-3844

Conversation

@Sayt-0

@Sayt-0 Sayt-0 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Closes #3844.

Adds a host-facing plan management contract used by a scriptable CLI and the TUI while preserving the existing plan and session_plan toolsets.

What changed

Shared plan storage and notifications

  • Reuses the existing pluggable plan.Storage backend through plan.SharedStorage().
  • Adds portable cross-process serialization for filesystem mutations:
    • flock on Unix;
    • LockFileEx on Windows;
    • documented in-process fallback where no OS lock primitive is available.
  • Keeps reads lock-free and safe through atomic rename publication.
  • Makes guarded cross-process races deterministic: one writer succeeds, stale writers receive the current revision.
  • Bounds plan content at 10 MiB and safely rejects oversized, corrupt, non-regular, FIFO, and device-backed files.
  • Replaces the former single callback slot with a race-safe multi-subscriber ChangeNotifier, registered once per runtime stream and released before its event channel closes.

pkg/plans host contract

A shared service interface for CLI and TUI:

  • explicit shared and session scopes;
  • list, get, create, update, status, export, and delete operations;
  • typed not-found, validation, corrupt, storage, conflict, and unsupported errors;
  • create-only and update-only semantics;
  • expected-version optimistic locking;
  • read-only session-plan integration with bounded reads;
  • stable host JSON fields using snake_case.

docker agent plans

plans list [--json] [--session <id>]
plans get [<name>] [--scope shared|session] [--session <id>]
plans create <name> --file <path> [--title|--author|--status] [--json]
plans update <name> --file <path> [--expected-version <n>|--force] [--json]
plans status <name> <status> [--expected-version <n>|--force] [--json]
plans export [<name>] --output <path> [--json]
plans delete [<name>] [--expected-version <n>|--force] [--json]
  • schema-versioned JSON success and error documents;
  • conflict exit code 3, generic failure exit code 1;
  • bounded regular-file and stdin input;
  • no TTY or TUI dependency;
  • machine-readable Cobra validation errors when --json has been parsed.

TUI preview

The TUI is rendered with the active theme; the schematic below shows the information hierarchy and controls without depending on terminal colors.

Plan browser

╭─ Plans ──────────────────────────────────────────────────────────────────────╮
│Filter: /release                                                              │
│                                                                              │
│  scope    plan                 status         ver   updated   title          │
│▸ shared   release-prep         in-progress    v7    2m ago    Release        │
│  shared   api-migration        blocked        v3    1d ago    API move       │
│  session  a1b2c3d4…            awaiting       -     5m ago    -              │
│                                                                              │
│↑/↓ navigate  Enter open  n new  e edit  s status  x export  d delete         │
╰──────────────────────────────────────────────────────────────────────────────╯

Plan detail

╭─ Plan: release-prep · shared ────────────────────────────────────────────────╮
│Scope: shared — collaborative, versioned                                      │
│Status: in-progress    Version: v7    Updated: 2m ago                         │
│Author: release-agent                                                         │
│                                                                              │
│# Release preparation                                                         │
│                                                                              │
│1. Validate the release configuration                                         │
│2. Run the compatibility test matrix                                          │
│3. Publish signed artifacts                                                   │
│                                                                              │
│↑/↓ scroll  r refresh  e edit  s status  x export  d delete  Esc back         │
╰──────────────────────────────────────────────────────────────────────────────╯

Session plans use the same browser/detail flow but are explicitly labeled read-only and unversioned. Destructive shared-plan actions name the plan and require confirmation.

TUI /plans

  • Discoverable plan browser and detail view.
  • Scope, identity, status, revision, updated time, title, and scrollable content.
  • Filter, keyboard and mouse navigation, create, external-editor update, status, export, and confirmed delete.
  • Guarded mutations with actionable stale-version handling and preserved editor drafts.
  • Asynchronous, bounded storage operations so slow or wedged storage does not freeze Bubble Tea's update loop.
  • Live refresh across sessions and background tabs, including dialogs buried in the stack.
  • Refresh coalescing, stale-session result rejection, duplicate-open guards, targeted idempotent detail closes, and duplicate-export protection.

Runtime event

PlanChangedEvent carries scope, name, action, and version without content. Every active runtime stream subscribed to the shared toolset receives the event; subscriptions are deduplicated and released with stream lifecycle.

Issue expectations

Issue expectation Implementation
Discover and inspect shared plans from the TUI /plans browser and detail dialog
Show scope, identity, status, version, and updated time Browser and detail metadata
Create/edit/status/export/delete with destructive confirmation TUI actions and CLI subcommands
Stable headless JSON contract schema_version: "1", snake_case fields, structured errors
Distinguish shared and session plans Explicit scopes; session mutations return unsupported
Prevent stale silent overwrites expected-version checks plus cross-process mutation lock
Distinct host errors Six typed service errors, covered by tests
Preserve existing plan tools/backends Shared storage interface and backward-compatible tool JSON
Document workflows and historical work CLI/TUI/tool docs with links to prior issues and PRs

Validation

Check Result
task build pass
task test pass
task test-binary pass
golangci-lint run and repository lint pass
Markdown/docs lint pass
Linux and Windows image builds in CI pass
Plan cross-compilation: Linux, Windows, js/wasm, wasip1, plan9 pass
Race tests for plan storage, runtime fan-out, CLI, and plan TUI paths pass
Real subprocess optimistic-lock race exactly one winner and one conflict
Real binary CLI lifecycle and exit-code smoke tests pass

Review history

The dedicated adversarial review team was run repeatedly against published PR heads. Its confirmed findings were corrected in follow-up commits, including cross-process races, notifier fan-out, bounded I/O, TUI lock waits, stale async results, duplicate dialogs/exports, and create-only revision-zero handling.

One unrelated pre-existing race remains in TestLoadSessionThenClickEditLabel (App.ReplaceSession versus session startup); the plan-focused race suites pass.

Known limitation

The TUI's default no-overwrite export remains a stat plus atomic write sequence. Duplicate in-process exports are prevented, but a separate external process can still create the destination during the narrow race window.

@Sayt-0

Sayt-0 commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

not finished yet don't approve or merge

@aheritier aheritier added area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tools For features/issues/fixes related to the usage of built-in and MCP tools area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jul 27, 2026
Sayt-0 added 9 commits July 28, 2026 21:38
… ChangeNotifier

- ValidateName: canonical name rule now exported so pkg/plans validates
  identically to the storage without duplicating the regexp.
- CorruptPlanError: typed error replacing the anonymous fmt.Errorf in
  load(); same message, cause wrapped, distinguishable via errors.As.
- SharedStorage(): returns the storage behind the process-wide singleton
  so a TUI and agent tools in one process share one mutex.
- ChangeNotifier interface + SetChangeCallback on ToolSet: fires after
  every successful write/status/delete, never on failures; stored as an
  atomic.Pointer so per-turn re-configuration from concurrent sessions is
  race-free. Discoverable through toolset wrappers via tools.As.
New pkg/plans wraps the existing plan.Storage and sessionplan helpers
behind a single Service interface consumed by both the CLI and the TUI,
so neither surface ever reads plan files directly.

- Unified Plan model with explicit Scope (shared | session) and nil
  Version for session plans (no revisions).
- Six typed host errors: NotFoundError, ValidationError, CorruptError,
  StorageError, ConflictError (with Expected/Current), UnsupportedError.
- Service operations: List, Get, Create, Update, SetStatus, Delete,
  Export — all with ExpectedVersion / force semantics.
- Create is create-only (expected revision 0); Update is update-only
  (MustExist); session mutations return UnsupportedError immediately.
- List with SessionID includes only that session's plan; stale session
  files are never enumerated globally.
- Export is atomic (atomicfile.Write + parent mkdir) for both scopes.
- 34 unit tests covering all error kinds, create-only conflict, stale
  update/status/delete preserving newer content, session scope,
  injected in-memory storage, and NewService nil-storage panic.
Implements the CLI surface from #3844: list, get, create, update, status,
export, delete — all built over pkg/plans.Service to avoid any direct
storage access.

Key behaviors:
- Mutations require exactly one of --expected-version <n> or --force
  (Cobra MarkFlagsOneRequired + MarkFlagsMutuallyExclusive); nil expected
  version is never sent by accident.
- --expected-version validated >= 1; create is guard-free (service
  enforces create-only via expected revision 0).
- Session plans (--session <id> / --scope session): list/get/export work;
  mutations surface the service's UnsupportedError with actionable reason.
- --json: stable schema_version:"1" envelope on stdout; single-line JSON
  error object on stderr with code/scope/name/op/expected_version/
  current_version, never mixing prose. All codes are typed, not text-parsed.
- Exit code 3 for conflicts, 1 for all other failures; Cobra SilenceErrors
  so stderr stays one object in --json mode.
- Service is built lazily inside RunE so plan.SharedStorage() does not
  resolve the data directory before --data-dir is applied.
- 30 hermetic CLI tests with injected temp-backed service.
Adds PlanChangedEvent (type plan_changed) carrying scope/name/action/
version but never content, so a UI refreshes through its own plan service
rather than trusting event payloads.

- configureToolsetHandlers wires a non-blocking callback via
  tools.As[plan.ChangeNotifier] — same pattern as the RAG event
  forwarder — so a stale sink can never hang a tool call.
- Event registered in the client registry so remote runtimes decode it
  as *PlanChangedEvent rather than dropping it.
- 3 tests: serialization (no content field), client decode over SSE,
  and end-to-end write/status/delete emit vs. failed-write no-emit
  through configureToolsetHandlers.
Implements the TUI host-management surface from #3844.

Slash command:
- /plans opens the plan browser (command palette discoverable, immediate,
  silently dropped in lean mode like /settings).

Browser dialog (pkg/tui/dialog/plan_browser.go):
- Lists shared plans + active session plan (no global session sweep),
  showing scope, identity, status, version (- for session), relative
  updated time, and title; all columns safely truncated.
- Keyboard: ↑/↓ navigate, / filter (Esc exits filter), Enter → detail,
  r refresh, x export, s set status, d delete (confirmation), n new plan
  (name dialog → $EDITOR), e edit ($EDITOR with pre-seeded content).
- Session plan rows show an explanatory notification instead of an action
  dialog for s/d/e.
- Double-click opens detail; warnings shown in footer.
- Mouse click navigates, double-click opens detail.

Detail dialog (pkg/tui/dialog/plan_detail.go):
- Explicit scope line ("shared — collaborative, versioned" /
  "session — owned by its session, read-only here"), all metadata,
  scrollable markdown-rendered content with width-cap fallback.
- Same action keys as browser; session scope hides s/e/d from help and
  shows an explanation instead.
- Implements PlanDetailViewer so the app can re-fetch the exact plan on
  refresh.

App handlers (pkg/tui/plans.go):
- Plans service built lazily (plan.SharedStorage() only resolved after
  --data-dir is applied); injectable for tests.
- All mutations version-guarded (never nil by accident); conflicts
  surface current version, keep newer content, refresh dialogs.
- Export: deterministic filename (<name>.md / session-plan-<8chars>.md),
  refuses to overwrite an existing file.
- Create/edit: temp file + tea.ExecProcess, drift detected before editor
  opens, draft kept on conflict.
- SessionPlanUpdatedEvent: refresh open dialogs only for the current
  session, forward to chat page.
- PlanChangedEvent: refresh open dialogs regardless of which session
  emitted it; background-tab mutations also refresh via handleRoutedMsg.

Broadcastable mechanism (pkg/tui/dialog/dialog.go):
- Messages implementing Broadcastable are delivered to all dialogs in
  the stack so a browser buried under the detail stays in sync.

19 browser/detail tests + 17 app-handler tests.
@Sayt-0
Sayt-0 force-pushed the feat/host-plan-management-3844 branch from c2a1f97 to 352d124 Compare July 28, 2026 19:40
@Sayt-0
Sayt-0 marked this pull request as ready for review July 28, 2026 19:46
@Sayt-0
Sayt-0 requested a review from a team as a code owner July 28, 2026 19:46
@aheritier
aheritier requested a review from docker-agent July 28, 2026 21:44

@aheritier aheritier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — first-class host UX for plan management

Head: 352d124a · CI: green (21 check-runs, all completed — 15 success incl. lint, build-and-test, license-check, CodeQL ×3, build-image linux/amd64+arm64 and the docs checks; 6 skipped push/merge jobs) · no commit statuses, nothing pending or failing · MERGEABLE, mergeStateStatus: BLOCKED.

Recommendation: do not merge or approve yet — the author states "not finished yet don't approve or merge". This is a COMMENT-only review; the findings below are for the author's next pass, not gates I'm claiming beyond that.

The shape of this is strong: the file-lock build-tag split (unix / windows / !unix && !windows) with an honest doc comment about where cross-process exclusion genuinely degrades, the never-deleted sentinel inode rationale, atomic temp+rename writes, optimistic locking mirrored consistently across tools/CLI/TUI, the non-blocking event sink with an explicit release path, the pointer-identity dedup and its panic-safety justification, and a genuinely dense test suite (subprocess lock contention, TUI scroll/selection preservation, JSON error contract). Comments explain why, per AGENTS.md.

Four points are inline. The rest:

1. Windows locking is compile-verified only (moderate). pkg/tools/builtin/plan/lock_windows.go implements the LockFileEx/UnlockFileEx path, and plan_lock_test.go is — correctly — untagged, so it would exercise cross-process locking on Windows. But no CI job runs on a Windows runner: .github/workflows/ci.yml has lint, build-and-test, license-check on ubuntu-latest and build-image on linux/amd64 + linux/arm64 only. task check-plan-cross proves the Windows path compiles, not that it locks — and locking correctness is the crux of this feature. A minimal windows-latest job running go test ./pkg/tools/builtin/plan/ ./pkg/plans/ would close the gap cheaply.

2. check-plan-cross covers the build tags but not the new callers (minor). All three lock variants are covered for ./pkg/tools/builtin/plan/ and ./pkg/plans/ (linux for unix, windows, and js/wasip1/plan9 for !unix && !windows) — good. Not covered: the new host code that also branches on GOOS, i.e. pkg/tui/plans.go (notepad vs vi) and cmd/root/plans.go; both packages ship //go:build !windows test files, so a Windows-only build break there would pass CI silently. Adding ./cmd/root/ ./pkg/tui/ to at least the GOOS=windows line would catch it.

3. plan_changed isn't documented on the API/A2A surfaces (nit). The event is registered in the runtime event registry (pkg/runtime/event.go) and decodes off the SSE client, so it reaches API and A2A consumers — but the docs updated here are TUI/CLI/tool pages only, and docs/features/api-server/index.md's "Event types include" list isn't extended. That list is already non-exhaustive, so this is discretionary; a new externally visible event type still seems like a good candidate for it.

4. Telemetry carries raw positional args (nit / privacy minimisation). runPlans builds trackArgs := append([]string{sub}, args...), so plan names and --session values reach TrackCommand/TrackCommandError, and the error variant also ships err.Error() (which can embed --output/--file paths). This matches the existing convention in cmd/root/{models,alias,pull,a2a,debug}.go, so it is not a regression introduced here. But plan names are user-authored content; tracking only sub — as cmd/root/sandbox_cmd.go already does — would be strictly better for a brand-new command group.

5. plan_detail.go has no dedicated test file (nit). Coverage is genuinely there — seven TestPlanDetail* tests plus the newTestPlanDetail helper — but they live in pkg/tui/dialog/plan_browser_test.go. AGENTS.md asks for tests alongside source files; splitting them into plan_detail_test.go would make the ~300-line dialog's coverage discoverable without changing a single assertion.

Comment thread pkg/tui/plans.go
Comment on lines +707 to +723
func (m *appModel) execPlanEditor(result planEditorClosedMsg) tea.Cmd {
parts := strings.Fields(cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR")))
if len(parts) == 0 {
if goruntime.GOOS == "windows" {
parts = []string{"notepad"}
} else {
parts = []string{"vi"}
}
}
args := append(parts[1:], result.path)
// The editor process is owned by tea.ExecProcess, so exec.Command is intentional.
cmd := exec.Command(parts[0], args...) //nolint:noctx // owned by tea.ExecProcess
return tea.ExecProcess(cmd, func(err error) tea.Msg {
result.err = err
return result
})
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor (refactor): this editor-resolution + launch block duplicates pkg/tui/tui.go:3141-3153 almost line for line — cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"))strings.Fieldsnotepad/vi GOOS fallback → append(parts[1:], path)exec.Command with the same //nolint:noctx // owned by tea.ExecProcess rationale.

There's already a pkg/tui/internal/editorname package that centralises the display name half of exactly this resolution (and duplicates the same Windows/Vi fallback decision a third time), so the launch half is the odd one out. Extracting one helper — e.g. editorname.Command(path) *exec.Cmd, or a small externalEditorCmd(path) in pkg/tui — would keep the sites from drifting; the Windows fallback and any future argument/flag handling are the likely divergence points.

Comment thread cmd/root/plans.go
Comment on lines +194 to +206
func hardenPlansValidation(cmd *cobra.Command) {
if inner := cmd.Args; inner != nil {
cmd.Args = func(cmd *cobra.Command, args []string) error {
return plansValidationError(cmd, inner(cmd, args))
}
}
cmd.PreRunE = func(cmd *cobra.Command, _ []string) error {
if err := cmd.ValidateRequiredFlags(); err != nil {
return plansValidationError(cmd, err)
}
return plansValidationError(cmd, cmd.ValidateFlagGroups())
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor (latent footgun): cmd.Args is chained defensively just above (if inner := cmd.Args; inner != nil), but cmd.PreRunE is assigned unconditionally — so any PreRunE already set on a plans subcommand is silently dropped. No subcommand in this file sets one today, so this isn't a live bug, and the root command's PersistentPreRunE is a separate hook that stays intact.

The concern is the asymmetry: the next person adding a PreRunE to a plans subcommand gets a silently skipped hook, not a compile error. cmd/root/root.go:244-246 already uses the capture-then-chain pattern for this exact reason:

inner := cmd.PreRunE
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
	if inner != nil {
		if err := inner(cmd, args); err != nil {
			return plansValidationError(cmd, err)
		}
	}
	if err := cmd.ValidateRequiredFlags(); err != nil {
		return plansValidationError(cmd, err)
	}
	return plansValidationError(cmd, cmd.ValidateFlagGroups())
}

Comment thread pkg/plans/service.go
Comment on lines +397 to +408
func writeExportFile(scope Scope, path, content string) error {
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil && info.IsDir() {
return &ValidationError{Message: fmt.Sprintf("path %q is a directory, not a file", path)}
}
if err := os.MkdirAll(filepath.Dir(clean), 0o700); err != nil {
return &StorageError{Scope: scope, Op: "export", Err: err}
}
if err := atomicfile.Write(clean, strings.NewReader(content), 0o600); err != nil {
return &StorageError{Scope: scope, Op: "export", Err: err}
}
return nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Moderate (behaviour / docs mismatch): writeExportFile only rejects a directory target; an existing file is silently overwritten by atomicfile.Write. The TUI export path deliberately does the opposite — it pre-checks and refuses, and its own error message points the user at this command as the escape hatch:

<path> already exists — move it away, or export to a custom path with 'docker agent plans export'.

and docs/tools/plan/index.md / docs/features/tui/index.md both document "An existing file is never overwritten" / "refusing to overwrite an existing file" for that path.

So the divergence looks deliberate, but it is undocumented where it matters: newPlansExportCmd's Long promises only that "parent directories are created and the write is atomic, so a reader never observes a partial export", and --output's help is just "Destination file for the plan content; required". A reader coming from the TUI's no-overwrite guarantee will reasonably assume the CLI is equally safe. Either state the overwrite explicitly in Long/--output, or apply the same guard here and add --force to opt out — which would also match the --expected-version/--force idiom the rest of the group already uses.

Comment thread pkg/runtime/loop.go
// Non-blocking sink like the RAG forwarder: the callback fires from
// another session's tool call, and a blocking send into this stream's
// events channel could hang that session's tool call.
func (r *LocalRuntime) subscribePlanChanges(sess *session.Session, events EventSink) (release func()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Question (design): the fan-out here is per-stream across every agent's toolsets plus sess.ExtraToolSets, deduped by pointer identity on what the doc comment describes as a process-wide *plan.ToolSet singleton. The consequence is that a mutation performed by session A's tool call is delivered to every open stream in the process, with a hardcoded "shared" scope and empty agent attribution.

For the TUI (one process, one user, one /plans browser to refresh) that is exactly the desired behaviour, and the comment justifies the dedup well. For a long-lived serve api / serve mcp / serve a2a process serving many concurrent sessions, though, every session's SSE stream also observes every other session's plan mutations — plan names included. Is that intended on the server surfaces, or should the fan-out be scoped there (opt-in per stream, or filtered by scope/session)? Either way it'd be worth one line in this doc comment: it currently explains the dedup and the non-blocking sink, but not the cross-session visibility that follows from a shared notifier.

@dgageot
dgageot merged commit 994b1a4 into main Jul 29, 2026
23 checks passed
@dgageot
dgageot deleted the feat/host-plan-management-3844 branch July 29, 2026 07:31
@aheritier

Copy link
Copy Markdown
Collaborator

See my comments for followups @Sayt-0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tools For features/issues/fixes related to the usage of built-in and MCP tools area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add first-class host UX for plan management

3 participants