26 KiB
Pi Management Operator Workflow Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a safe thothctl pi restart command and replace the Pi Management dialog's duplicated technical copy with a structured Docker-only operator workflow.
Architecture: Keep image changes in the existing recoverable pi update transaction. Implement configuration reload as a separate restart transaction that retains the current image, shares the Pi lifecycle lock, and uses a separate recovery file so the latest update remains rollback-capable. The React dialog remains a settings/diagnostics surface and only explains platform-specific host actions.
Tech Stack: Go 1.26, Docker Compose v2, React 18, TypeScript, TanStack Query, Tailwind CSS, Vitest, Testing Library, shell documentation gates.
Spec: docs/superpowers/specs/2026-08-14-pi-management-operator-workflow-design.md
Global Constraints
- Pi runs only inside the Docker Compose
coreservice; add no host-Pi or raw-Compose operator guidance. pi restartrequires--yes; without--drainit refuses active sessions, and with--drainit waits without terminating them.- Restart retains the currently selected image and never builds, pulls, or promotes an image.
- Restart and update use separate recovery files but one installation-scoped lifecycle lock.
- The browser receives no Docker access, shell, credential values, or write access to
deploy/pi/*.json. - Use
~for GUI home-directory examples. All GUI strings remain English. - Platform tabs remain closed initially; dialog and instruction scrolling remain functional.
- Do not stage or modify the unrelated existing changes in
frontend/src/shell/WorkspaceManager.tsxandfrontend/src/shell/WorkspaceManager.test.tsx. PiManagement.tsxand its test contain approved uncommitted work from earlier revisions; edit and commit them only in the frontend task.
File map
- Create
tools/thothctl/internal/pi/restart.goandrestart_test.gofor the restart transaction. - Modify
tools/thothctl/internal/pi/state.goandstate_test.gofor one shared lifecycle lock. - Modify
tools/thothctl/internal/config/installation.goand its test forrestart-state.json. - Modify
tools/thothctl/internal/pi/update.goand its test to share the active-session drain helper and compose restart recovery. - Modify the
Target.Sourcecomment intools/thothctl/internal/pi/state.gowhen"restart"becomes a valid non-image lifecycle source. - Modify
tools/thothctl/cmd/thothctl/main.goand its test for usage, parsing, dispatch, and recovery. - Modify
frontend/src/shell/PiManagement.tsxand its test for the structured workflow. - Modify
docs/contracts/thothctl-pi.md,docs/install/pi-management.md,docs/general/pi-configuration.md, andscripts/verify-workspace-install-docs.sh. - Modify
README.mdonly if it claims to list the complete Pi command surface.
Task 1: Separate restart state and share the lifecycle lock
Files:
- Modify:
tools/thothctl/internal/config/installation.go:234-249 - Test:
tools/thothctl/internal/config/installation_test.go - Modify:
tools/thothctl/internal/pi/state.go:171-224 - Test:
tools/thothctl/internal/pi/state_test.go
Interfaces:
-
Produces:
func (Installation) RestartStatePath() string -
Produces:
func lifecycleLockPath(statePath string) string -
Preserves:
func acquireLock(statePath string) (*updateLock, error) -
Step 1: Write failing path and cross-operation lock tests
Add to installation_test.go:
if got, want := installation.RestartStatePath(), filepath.Join(installation.ControlDirectory(), "restart-state.json"); got != want {
t.Fatalf("RestartStatePath() = %q, want %q", got, want)
}
Add to state_test.go:
func TestUpdateAndRestartStatePathsShareOneLifecycleLock(t *testing.T) {
dir := t.TempDir()
first, err := acquireLock(filepath.Join(dir, "update-state.json"))
if err != nil {
t.Fatal(err)
}
defer first.Release()
second, err := acquireLock(filepath.Join(dir, "restart-state.json"))
if !errors.Is(err, ErrLockHeld) || second != nil {
t.Fatalf("second lock = %#v, %v; want nil, ErrLockHeld", second, err)
}
}
- Step 2: Run the focused tests and verify RED
cd tools/thothctl
go test ./internal/config ./internal/pi -run 'Test.*(RestartStatePath|ShareOneLifecycleLock)' -count=1
Expected: compile failure for RestartStatePath, then lock-test failure until both files resolve to one lock.
- Step 3: Implement the installation path and common lock
Add to installation.go:
func (i Installation) RestartStatePath() string {
return filepath.Join(i.ControlDirectory(), "restart-state.json")
}
Change lock derivation in state.go:
func lifecycleLockPath(statePath string) string {
return filepath.Join(filepath.Dir(statePath), "pi-lifecycle.lock")
}
var ErrLockHeld = errors.New("another Pi update, restart, or rollback is already in progress")
Keep acquireLock(statePath) but set path := lifecycleLockPath(statePath). Preserve owner metadata and durable cleanup.
- Step 4: Run config, state, update, and rollback tests
cd tools/thothctl
go test ./internal/config ./internal/pi -count=1
Expected: PASS.
- Step 5: Commit
git add tools/thothctl/internal/config/installation.go tools/thothctl/internal/config/installation_test.go tools/thothctl/internal/pi/state.go tools/thothctl/internal/pi/state_test.go
git commit -m "refactor(thothctl): share Pi lifecycle lock"
Task 2: Implement the core-only restart transaction
Files:
- Create:
tools/thothctl/internal/pi/restart.go - Create:
tools/thothctl/internal/pi/restart_test.go - Modify:
tools/thothctl/internal/pi/update.go:108-145,802-849 - Test:
tools/thothctl/internal/pi/update_test.go
Interfaces:
- Consumes existing
Runner,lifecycleHooks, lock, maintenance, session inventory,Doctor,Status,renderedCore,runningImage,recreateCore, state, and recovery helpers. - Produces:
type RestartRequest struct {
StatePath string
UpdateStatePath string
Confirm bool
Drain bool
}
type RestartResult struct {
StatePath string
Version string
}
func Restart(context.Context, Runner, RestartRequest) (RestartResult, error)
func RecoverLifecycleMaintenance(context.Context, Runner, string, string, bool) error
- Step 1: Write failing restart transaction tests
Create restart_test.go in package pi, reusing newFakeRunner, assertCalled, and assertNotCalled:
func TestRestartRequiresConfirmationWithoutInvokingCompose(t *testing.T) {
dir := t.TempDir()
fake := newFakeRunner()
_, err := Restart(context.Background(), fake, RestartRequest{
StatePath: filepath.Join(dir, "restart-state.json"),
UpdateStatePath: filepath.Join(dir, "update-state.json"),
})
if !errors.Is(err, ErrConfirmationRequired) {
t.Fatalf("Restart() error = %v, want ErrConfirmationRequired", err)
}
assertNotCalled(t, fake.calls, "compose")
}
func TestRestartDrainsRecreatesOnlyCoreAndRetainsImage(t *testing.T) {
fake := newFakeRunner()
fake.activeSessions = true
dir := t.TempDir()
hooks := defaultLifecycleHooks
hooks.sleep = func(time.Duration) { fake.activeSessions = false }
result, err := restartWithHooks(context.Background(), fake, RestartRequest{
StatePath: filepath.Join(dir, "restart-state.json"),
UpdateStatePath: filepath.Join(dir, "update-state.json"),
Confirm: true,
Drain: true,
}, hooks)
if err != nil {
t.Fatal(err)
}
if result.Version != fake.version {
t.Fatalf("version = %q, want %q", result.Version, fake.version)
}
assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
assertNotCalled(t, fake.calls, "build --pull")
assertNotCalled(t, fake.calls, "pull ")
assertNotCalled(t, fake.calls, "frontend")
if _, err := os.Stat(result.StatePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("successful restart state still exists: %v", err)
}
}
Also add:
TestRestartRefusesActiveSessionsWithoutDrainTestRestartRefusesInterruptedUpdateOrRestartStateTestRestartPreflightFailureNeverRecreatesCoreAndClearsMaintenanceTestRestartPostRecreateFailureKeepsMaintenanceAndRecoveryStateTestRecoverLifecycleMaintenanceVerifiesAndClearsRestartStateTestRestartRejectsImageConfigurationAndMountDrift
Extend the shared fake runner with a recreated bool field, set it when the force-recreate call is
observed, and make the existing post-candidate failure branches apply when fake.built || fake.recreated. This lets restart failures occur after mutation without pretending an image build
happened. For post-recreate failure set fake.fail = "health", then assert maintenance remains
true and restart-state.json remains.
- Step 2: Run restart tests and verify RED
cd tools/thothctl
go test ./internal/pi -run 'TestRestart|TestRecoverLifecycleMaintenance' -count=1
Expected: compile failure for the missing restart interfaces.
- Step 3: Extract the existing active-session loop
Move the 30-second loop from updateWithHooks into update.go:
func waitForInactiveSessions(ctx context.Context, runner Runner, drain bool, sleep func(time.Duration)) error {
running, err := activeSessions(ctx, runner)
if err != nil {
return err
}
if !running {
return nil
}
if !drain {
return ErrActiveSessions
}
for attempts := 0; attempts < 30; attempts++ {
running, err = activeSessions(ctx, runner)
if err != nil {
return err
}
if !running {
return nil
}
sleep(time.Second)
}
return ErrActiveSessions
}
Replace the original update loop with waitForInactiveSessions(...). Preserve the second inventory check immediately before mutation.
- Step 4: Implement
Restartinrestart.go
Implement Restart as a wrapper around restartWithHooks. The transaction must execute in this exact order:
- validate both state paths;
- acquire the shared lock using
RestartStatePath; - require confirmation;
- reject recovery-required update or restart state;
- activate maintenance and arrange cleanup for pre-mutation returns;
- wait/refuse through
waitForInactiveSessions; - run
Doctoras preflight; - read current version, rendered core, running image, configuration SHA, and mount identity;
- write restart state with
Target{Version: version, Source: "restart"}; - recheck active sessions;
- durably mark
MutationStarted; - call
recreateCore(ctx, runner)directly, without image override; - prove maintenance remains active;
- record
PhaseRecreated; - run
verifyRestart(ctx, runner, version, previous); - record
PhaseVerified, remove onlyrestart-state.json, and clear maintenance.
Implement verifyRestart to run Doctor, reload rendered configuration and running image, require
the same image ID, require the same ConfigurationSHA, and require sameMounts(previous.Mounts, after.Mounts). Use stable errors for image, external-configuration, and persistence-mount drift.
Update the Target.Source comment in state.go to include the non-image restart operation.
Use:
func Restart(ctx context.Context, runner Runner, request RestartRequest) (RestartResult, error) {
return restartWithHooks(ctx, runner, request, defaultLifecycleHooks)
}
Use recoveryRequired("Pi restart ...", err) for every post-mutation failure and set the deferred maintenance cleanup flag to false. Do not overwrite or remove a verified update-state.json; it remains the image rollback record.
- Step 5: Implement combined maintenance recovery
Add:
func RecoverLifecycleMaintenance(
ctx context.Context,
runner Runner,
updateStatePath string,
restartStatePath string,
confirm bool,
) error
When restart state requires recovery, run verifyRestart using the recorded target version and
previous image contract, and remove restart-state.json only after verification. Then call existing
update-state RecoverMaintenance without opening admission between the two checks. Missing restart
state is allowed; malformed restart state fails closed.
- Step 6: Run internal Pi tests and verify GREEN
cd tools/thothctl
go test ./internal/pi -count=1
Expected: PASS, including existing update, rollback, mount identity, durability, and transport-loss tests.
- Step 7: Commit
git add tools/thothctl/internal/pi/restart.go tools/thothctl/internal/pi/restart_test.go tools/thothctl/internal/pi/update.go tools/thothctl/internal/pi/update_test.go tools/thothctl/internal/pi/state.go
git commit -m "feat(thothctl): add safe Pi core restart"
Task 3: Expose pi restart through thothctl
Files:
- Modify:
tools/thothctl/cmd/thothctl/main.go:25-53,263-363,470-520 - Test:
tools/thothctl/cmd/thothctl/main_test.go:68-110,567-666
Interfaces:
-
Consumes Task 2's restart and recovery interfaces and Task 1's state paths.
-
Produces
func parsePiRestartArgs([]string, string, string) (pi.RestartRequest, error). -
Public syntax:
thothctl --installation <path> pi restart --yes [--drain]. -
Step 1: Write failing usage, parser, and dispatch tests
Extend the usage test:
for _, expected := range []string{
"pi restart --yes [--drain]",
"Recreate only core with the currently selected Pi image",
} {
if !strings.Contains(usage, expected) {
t.Fatalf("usage missing %q", expected)
}
}
Add a table test for:
{name: "confirmed", args: []string{"--yes"}, want: pi.RestartRequest{StatePath: restartPath, UpdateStatePath: updatePath, Confirm: true}}
{name: "drain", args: []string{"--yes", "--drain"}, want: pi.RestartRequest{StatePath: restartPath, UpdateStatePath: updatePath, Confirm: true, Drain: true}}
{name: "duplicate yes", args: []string{"--yes", "--yes"}, wantErr: "--yes may be supplied once"}
{name: "duplicate drain", args: []string{"--drain", "--drain"}, wantErr: "--drain may be supplied once"}
{name: "unknown", args: []string{"--force"}, wantErr: "unknown pi restart option"}
Add command tests proving missing --yes exits 2 before mutation, success is sanitized, and maintenance recovery passes both state paths.
- Step 2: Run focused CLI tests and verify RED
cd tools/thothctl
go test ./cmd/thothctl -run 'Test.*(Restart|Usage|Maintenance)' -count=1
Expected: failure because parsing and dispatch are absent.
- Step 3: Add usage, parser, dispatch, and recovery routing
Add:
pi restart --yes [--drain]
Recreate only core with the currently selected Pi image and verify readiness.
Dispatch before case "update":
case "restart":
request, err := parsePiRestartArgs(
args[1:],
installation.RestartStatePath(),
installation.UpdateStatePath(),
)
if err != nil {
return commandUsageError(stderr, err.Error())
}
result, err := pi.Restart(ctx, controlled, request)
if err != nil {
return piFailure(stderr, err, secretValues)
}
fmt.Fprintf(stdout, "Pi core restarted with the existing image; version %s readiness and smoke checks passed.\n", result.Version)
return 0
Implement exact duplicate and unknown-option errors from the tests. Route pi maintenance recover --yes through RecoverLifecycleMaintenance with both state paths.
- Step 4: Run CLI and full Go tests
cd tools/thothctl
go test ./cmd/thothctl -count=1
go test ./... -count=1
Expected: PASS with no secret leakage.
- Step 5: Build supported binaries
cd ../..
./scripts/build-thothctl.sh
Expected: supported artifacts under dist/thothctl/, exit 0.
- Step 6: Commit
git add tools/thothctl/cmd/thothctl/main.go tools/thothctl/cmd/thothctl/main_test.go
git commit -m "feat(thothctl): expose Pi restart command"
Task 4: Update contracts, operator docs, and documentation gates
Files:
- Modify:
docs/contracts/thothctl-pi.md - Modify:
docs/install/pi-management.md - Modify:
docs/general/pi-configuration.md - Modify:
scripts/verify-workspace-install-docs.sh:1160-1188 - Modify:
README.mdonly if it claims command completeness.
Interfaces:
-
Consumes the exact Task 3 syntax and Task 2 recovery behavior.
-
Produces one consistent distinction among GUI defaults, host files, credentials, reload, update, and recovery.
-
Step 1: Strengthen the documentation gate first
Require:
"pi restart --yes --drain" \
"restart only core" \
"deploy/pi/models.json" \
"deploy/pi/settings.json" \
"PI_AUTH_FILE" \
"pi update" \
"pi rollback --yes" \
"pi maintenance recover --yes"
Add this negative gate:
if grep -Fq '~/.pi/agent/' "$guide"; then
echo "Pi management guide must not direct ThothII operators to native Pi paths" >&2
return 1
fi
- Step 2: Run the documentation verifier and verify RED
./scripts/verify-workspace-install-docs.sh
Expected: FAIL because restart and separated workflows are not documented.
- Step 3: Rewrite the two authoritative operator documents
In docs/contracts/thothctl-pi.md, document pi restart --yes [--drain], confirmation, maintenance, bounded drain, current-image retention, core-only recreation, verification, separate restart state, shared lock, and recovery.
In docs/install/pi-management.md, use these headings:
-
Choose application defaults — GUI Save defaults or CLI configure, not both.
-
Edit the provider catalog and enabled-model policy — project-root
deploy/pi/files. -
Store provider credentials —
PI_AUTH_FILEfrom the installation environment file. -
Reload changed configuration — one restart command.
-
Update the bundled Pi version — build command and digest-pinned pull alternative.
-
Recover a failed lifecycle operation — status, logs, rollback, maintenance recovery.
-
Step 4: Add the Docker-operator callout
Add below the introduction of docs/general/pi-configuration.md:
> **ThothII operator note:** ThothII runs Pi only in Docker Compose. Paths under
> `~/.pi/agent/` in this document describe Pi's container-side behavior. Operators edit
> `deploy/pi/models.json` and `deploy/pi/settings.json` in the ThothII project root and use
> the protected host credential file selected by `PI_AUTH_FILE`; they do not edit files inside
> the running container.
- Step 5: Run documentation gates
./scripts/verify-workspace-install-docs.sh
./scripts/test-thothctl-build-contract.sh
Expected: both exit 0.
- Step 6: Commit
git add docs/contracts/thothctl-pi.md docs/install/pi-management.md docs/general/pi-configuration.md scripts/verify-workspace-install-docs.sh
git commit -m "docs: clarify Pi reload and update workflows"
Add README.md only if it changed.
Task 5: Restructure the Pi Management dialog
Files:
- Modify:
frontend/src/shell/PiManagement.tsx:1-225,286-305,376-449 - Test:
frontend/src/shell/PiManagement.test.tsx:170-230
Interfaces:
-
Consumes the public Task 3 commands.
-
Preserves API calls, readiness rail, defaults, test, logs, all-tabs-closed state, dialog height, and scrolling.
-
Removes
UPDATE_COMMAND,copyUpdateCommand, global clipboard assertions, and the bottom Host update section. -
Step 1: Rewrite the frontend test first
Add these assertions:
expect(screen.getByText("Using the host terminal:")).toBeVisible();
expect(screen.queryByText(/not this browser page/i)).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Host update" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy update command" })).not.toBeInTheDocument();
expect(screen.queryByText(/:5173/)).not.toBeInTheDocument();
await user.click(within(tablist).getByRole("tab", { name: "Linux" }));
const linux = screen.getByRole("tabpanel", { name: "Linux" });
expect(within(linux).getAllByRole("listitem")).toHaveLength(7);
expect(linux).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(linux).toHaveTextContent("deploy/pi/models.json");
expect(linux).toHaveTextContent("deploy/pi/settings.json");
expect(linux).toHaveTextContent("baseUrl is the provider API endpoint");
expect(linux).toHaveTextContent("enabledModels uses provider/model identifiers");
expect(linux).toHaveTextContent("PI_AUTH_FILE is a setting in the installation environment file");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain");
expect(linux).toHaveTextContent("pi update --version <VERSION> --source build --yes --drain");
expect(linux).toHaveTextContent("pi rollback --yes");
expect(linux).not.toHaveTextContent("~/.pi/agent/");
Add equivalent macOS and Windows path/command assertions. Preserve tests for tabs closed, dialog max-h-[calc(100vh-6rem)], and overflow-y-scroll.
Add:
expect(screen.getByText("Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.")).toBeVisible();
expect(screen.getByText("Shows at most 200 recent lines with declared secret values removed.")).toBeVisible();
- Step 2: Run the focused test and verify RED
cd frontend
npx vitest run src/shell/PiManagement.test.tsx
Expected: FAIL on the new lead-in, ordered workflow, restart command, explanations, removed duplicate section, and revised descriptions.
- Step 3: Introduce shared structured platform data
Define:
type PiPlatformDetails = {
modelsPath: string;
settingsPath: string;
terminal: string;
credentialProtection: string;
restartCommand: string;
updateCommand: string;
pullCommand: string;
recoveryCommands: string;
};
Render PiInstructionSteps as an ordered list with exactly these seven headings:
- Open the project root
- Edit the provider catalog
- Enable the model
- Set the provider credential
- Reload Pi configuration
- Update the Pi version
- Recover a failed update
Use these normal commands:
Linux/macOS:
~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain
~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain
Windows PowerShell:
& (Resolve-Path "~\bin\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\thothii-installation.yaml") pi restart --yes --drain
& (Resolve-Path "~\bin\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\thothii-installation.yaml") pi update --version <VERSION> --source build --yes --drain
Explain baseUrl, api, models, id, name, enabledModels, and PI_AUTH_FILE in compact lists. The lead-in must be exactly:
<p className="text-muted-foreground">Using the host terminal:</p>
Keep digest-pinned pull and recovery commands visually subordinate.
- Step 4: Remove duplicate and developer-only content
Delete:
UPDATE_COMMANDcopyUpdateCommand- the bottom
Host updatesection Clipboardimport if unused- Vite,
:5173, frontend rebuild, native Pi, and container-edit text - mandatory configure, stop/start, status/doctor/test sequences
Keep the positive statement that Docker mounts the selected host credential file read-only for Pi.
- Step 5: Clarify defaults and diagnostics
Use exactly:
Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.
Shows at most 200 recent lines with declared secret values removed.
Do not change API behavior or readiness semantics.
- Step 6: Run focused and full frontend gates
cd frontend
npx vitest run src/shell/PiManagement.test.tsx
npx vitest run
npx tsc -b
npm run build
Expected: focused tests, full suite, typecheck, and production build pass.
- Step 7: Commit only Pi Management files
git add frontend/src/shell/PiManagement.tsx frontend/src/shell/PiManagement.test.tsx
git commit -m "feat(frontend): simplify Pi operator workflow"
Confirm Workspace Manager files remain unstaged.
Task 6: Final verification and local deployment
Files:
- Verify only; change source only to correct a failing gate attributable to Tasks 1-5.
Interfaces:
-
Produces fresh evidence that CLI, docs, frontend, and port 8080 agree.
-
Step 1: Run all relevant gates
cd tools/thothctl
go test ./... -count=1
cd ../..
./scripts/build-thothctl.sh
./scripts/test-thothctl-build-contract.sh
./scripts/verify-workspace-install-docs.sh
cd frontend
npx vitest run
npx tsc -b
npm run build
cd ..
git diff --check
Expected: every command exits 0. Existing Vite chunk-size warnings are acceptable.
- Step 2: Verify the built CLI
dist/thothctl/thothctl-darwin-arm64 --help
Expected: output contains pi restart --yes [--drain] plus update, rollback, and maintenance commands.
- Step 3: Rebuild only the active frontend service
Use project thothii-9307255178c1, the current installation environment values, and these Compose files:
docker compose --project-name thothii-9307255178c1 \
-f compose.yaml \
-f deploy/compose.local.yaml \
-f deploy/compose.git-ssh.yaml \
-f deploy/psd/connector-secrets.yaml \
up -d --build --no-deps frontend
Never print or inline credential contents.
- Step 4: Verify port 8080 and health
Fetch http://127.0.0.1:8080/ and its JavaScript assets. Require:
Using the host terminal:
pi restart --yes --drain
PI_AUTH_FILE is a setting in the installation environment file
The deploy directory is in the ThothII project root
Reject:
not this browser page
For native Pi outside Compose
Copy update command
:5173
Run:
docker ps --format '{{.Names}}|{{.Status}}'
Expected: thothii-9307255178c1-frontend-1 is healthy.
- Step 5: Inspect final scope
git status --short
git log --oneline -6
Expected: lifecycle state, restart transaction, CLI, docs, and frontend commits are present. Only unrelated pre-existing Workspace Manager changes remain.