Skip to content

feature: loops and bugs and ux#40

Open
JheisonMB wants to merge 204 commits into
developfrom
docs/initial-docs
Open

feature: loops and bugs and ux#40
JheisonMB wants to merge 204 commits into
developfrom
docs/initial-docs

Conversation

@JheisonMB

Copy link
Copy Markdown
Collaborator

What changed

Why

Related issue

Checklist

  • Local checks pass (build/lint/tests)
  • Docs updated when behavior changed
  • PR is focused and avoids unrelated edits
  • Commits follow Conventional Commits

Notes (optional)

JheisonMB added 30 commits June 12, 2026 18:10
…stall

- agents.rs: replace manual ANSI parser (depth 5) with regex (depth 1)
- process.rs: extract parse_pids_from_ss and terminate_process (depth 6 → 3)
- vector_store.rs: extract shared query_file_paths helper, deduplicate logic
- service_install.rs: extract ensure_linger_enabled and reload_and_enable_service

Also applies cargo fmt to 16 other files with inconsistent formatting.
All 421 tests pass, clippy clean.
JheisonMB and others added 30 commits July 18, 2026 06:37
… advancing

retry_current_node was a no-op — pool dispatch only picks pending specs,
so the running spec was invisible and the next queue member ran instead.

Changes:
- db/pools.rs: pool_running_spec_id() finds first running pool member
- loop_engine.rs: run_loop_dispatch checks for running spec before
  pending-picker loop when is_resume=true
- db/loops.rs: reconcile_stranded_pool_specs() resets pool specs stuck
  running with no active run, called at startup
- handler.rs: retry_current_node validates a running spec exists
- server.rs: call reconcile_stranded_pool_specs at startup

7 new tests, all 1150 pass.
A prompt-only rule telling non-committer nodes not to commit has been
broken by three different models. Move the rule into the engine.

A node may commit only if its config declares `commit_rights: true`.
The engine snapshots HEAD before a node runs and compares it after; a
node that moved history without the right fails, overriding whatever
verdict it reported — a clean exit code and a `loop_complete_node`
self-report of `pass` are both overridden — and the failure is
persisted on the run row so `canopy loop info` shows it.

Enforcement is per-graph opt-in: it activates only once some node in the
graph declares the right, because a graph that designates nobody cannot
be told apart from one whose committer predates this key, and enforcing
there would fail the very node the graph relies on to land work.

Ensembles are enforced at quorum granularity — members run concurrently
against one workdir, so a moved HEAD cannot be attributed to a single
member. Non-git workdirs are skipped.

6 tests, 1156 pass.
The B19/B26 infra-crash retry treated every quick agent failure as
transient, so a deterministic spawn failure — chiefly a missing CLI
binary — burned the whole retry budget and its doubling backoff waiting
for a state that cannot change between attempts.

Classify spawn failures by kind, not by matching the rendered message:

- cli_strategy::resolve_binary now returns a typed BinaryResolutionError
  instead of a bare anyhow string, so a missing binary is distinguishable
  at the source.
- A new SpawnError wraps build/spawn failures and derives a permanent
  reason from the error kind (typed BinaryResolutionError, io NotFound /
  PermissionDenied); everything else stays transient.
- agent_spawn_failure records spawn_permanent plus an infra_retry_skipped
  reason on the run output, so an operator can tell 'failed fast on
  purpose' from 'retried and gave up'.
- is_infra_crash returns false when spawn_permanent is set, so a permanent
  failure produces exactly one run row with no infra_attempt marker.

The transient path is unchanged: retry limit, backoff, and the
iteration-budget exemption are untouched, and the existing B19/B26 tests
pass as-is.
…e (B42)

Terminating a node run because a newer attempt superseded it is engine
bookkeeping, not a node failure. It was being finalized as a `Fail` row and
then routed down the graph's fail edge, which — with a resilience node on that
edge — manufactured a fresh triage run per killed attempt, and each triage
pass spawned the next attempt: an unbounded runaway that let no node complete.

- run_spec now recognises the supersede marker on a resolved run and returns a
  new terminal-and-silent `SpecExecutionOutcome::Superseded`: the owning
  dispatch evaluates no edge, fails nothing, and completes nothing, leaving the
  loop to whichever dispatch now owns it.
- run_loop_dispatch claims the loop via a single-statement compare-and-set
  (fireable -> running only if not already running) instead of an
  unconditional status write. This is the one guarded entry point every launch
  path funnels through, so the autorun-vs-resume check-then-act race (one reads
  the loop as failed, the other hasn't written running yet) resolves to exactly
  one winner; the loser no-ops rather than starting a duplicate, superseding
  run. loop_continue no longer pre-flips the status — it converges on the claim.

Genuine agent failures (nonzero exit, self-reported fail, timeout) and the
B19/B26 infra-retry path are untouched. Adds a regression test that drives the
exact chain (in-flight run + supersede -> no edge traversed, no resilience run
created) and one for the duplicate-launch no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Node launches, completions, terminations and edge traversals produced
no log output, so diagnosing a loop meant reading ps and SQLite. Emit
INFO lines for each transition:

- launch: loop/spec/node ids, node name, run id, platform, model
- completion: run id, status
- edge route: run id, from/to node, status (or no edge matched)
- termination: run id, node id, reason

Every line carries the run id so a single grep reconstructs one run
end to end. Ensemble member runs log the same lifecycle with their
ensemble id. Only ids and status are logged, never prompts or agent
output, so volume tracks transitions rather than agent chatter.
agent_models now returns, for each platform, the literal model string that
platform's CLI accepts. Previously opencode ids were emitted bare
(`mimo-v2.5-free`) with the provider only as a parenthetical label, but
opencode's `-m` flag requires the `provider/model` form
(`opencode/mimo-v2.5-free`); the bare form fails at runtime with a generic
server error, which was misdiagnosed as a flaky gateway for days. Platform-
native models absent from models.dev (`mimo-v2.5-free`, `big-pickle`) were
also missing entirely.

The rule is registry-driven, not a CLI-name check: `CliConfig` gains
`models_list_cmd` (opencode: `models`). When a platform declares an
enumeration command, agent_models runs it as the authoritative source of
passable ids — each output line is the exact string the model flag accepts,
prefix and all — and skips models.dev, which cannot know a gateway's
prefixed form or its private zen catalog. Platforms without an enumeration
command (claude) keep the bare models.dev-derived ids, which are correct.

The native enumeration is cached like the models.dev catalog (its own
`models_native_<cli>.json`, same TTL and live|cache|stale provenance) and
runs off the hot path in spawn_blocking, so a fresh cache serves with no CLI
call. Output stays bounded by the existing per-provider caps. The rendered
id is always the first token on the line with any human label parenthesized,
so a caller can copy it verbatim.

Tested with a mocked fetch and a mocked enumeration asserting the emitted id
form per platform (opencode prefixed, claude bare), plus the native cache
staleness policy; cross-checked against real `opencode models` output.
…install (B38)

Binary resolution now converges on setup's model: absolute path -> PATH
lookup -> not found. The `~/.<binary>/bin/<binary>` install-dir guess is
removed; it was derived from the binary name and wrong precisely when
needed (e.g. mimo lives at ~/.mimocode/bin/mimo, not ~/.mimo/bin/mimo).

The real fix is the daemon's PATH. `canopy daemon install` now captures
PATH from the installing user's process environment and writes it into
the generated systemd unit, so a service-managed daemon sees the same
CLIs the user does. Regenerating merges rather than shrinks: existing
Environment=PATH= entries are preserved so a reinstall never breaks
already-working CLIs.

BinaryResolutionError collapses to a single struct whose message names
the binary and the exact PATH searched.
Daemon-startup reconciliation now marks a node run interrupted by a
restart/kill with a distinct, recorded marker instead of a plain fail
so canopy loop info never attributes the damage to whichever node runs
next. Any uncommitted worktree changes are stashed via `git stash push
-u`, bounded by the spec's recorded spec_start_head, rather than
reverted or left to be silently overwritten — the stash is idempotent
and recoverable with `git stash list` / `git stash pop`.

reconcile_stranded_pool_specs now also catches a loop_runs row still
stuck at 'running' from a stale boot or dead pid for a pool member
whose loop already paused, applying the same distinct marking and
quarantine before resetting the spec to pending.
Setup and doctor detection called which::which(binary) directly while
the spawner resolved through resolve_binary_with_path, so a CLI could
report as installed at setup time yet fail to spawn from the daemon.
Route every detection call site (setup_module::models,
skills_module::download, cli_config::CliConfig) through the same
resolve_binary_in primitive the spawner uses, so the two can never
disagree.

canopy doctor now reports each configured CLI's resolution step and
absolute path, and compares against the daemon's captured
Environment=PATH= (read from the systemd user unit) to warn when a
CLI only resolves under the interactive shell's PATH and would fail
under the daemon.
Windows Action Center piled up duplicate toasts for the same subject
(e.g. a mission unlock) because send_wsl never set Tag/Group, so every
Show() was additive. Derive a stable per-subject tag from the title
(already the natural subject key at every call site) so re-sending the
same subject replaces its entry instead of adding a new one; distinct
subjects and the 24h additive retention are unaffected. This is now
the authoritative anti-pile-up mechanism, holding regardless of exit
path; startup cleanup remains as best-effort hygiene and now logs
failures instead of swallowing them.
Replace the flat sidebar (Projects/Loops/Backlog/Knowledge/History as
top-level siblings) with three collapsible layers between the pinned
RAG summary and sysinfo dashboard: Live (interactive agents +
terminals), Automation (background agents + loops, global and always
visible), and Knowledge (the projects list). Everything project-scoped
now lives inside each project: a cheap cached Preview summary card on
highlight, and an Overview | Backlog | Knowledge | History tab bar on
Enter, reusing the sidebar's existing mouse hit-test/dispatch
infrastructure for tabs and rows.

The project History tab now reads finished loops and past
interactive/terminal sessions straight from SQLite (scoped to the
project's workdir), fixing the old behavior where History only showed
what the live provider had observed since the TUI started.

Also fixes a bug found in review: draw_projects_list/draw_knowledge_body
took &App instead of &mut App, so project_click_map was never
populated and a mouse click on a sidebar project row silently did
nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…phaned artifacts

The 2026-07-12 incident required an emergency SQL delete of 829 stale
orphaned/error interactive_sessions rows flooding the TUI after WSL
crashes. `canopy clean` (soft mode, default) removes stale
orphaned/error/completed sessions and their orphaned log/terminal/RAG
artifacts within a configurable retention window (default 7 days),
never touching active/resumed sessions, and reports (without deleting)
projects whose workdir no longer exists. Supports --dry-run and
--older-than.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…MCP tools

Replaces static global-skill installation with on-demand fetch: skills are
cloned from configurable [skills] git sources into ~/.canopy/skills/, kept
fresh via a TTL'd commit-hash check (network failure serves the stale copy
with a WARN), and served to any harness via new skill_list/skill_get MCP
tools. Reuses skills_module's find_skill_instructions/list_skill_dirs and
leaves the existing global-symlink standard untouched.

Validates skill_get's caller-supplied name as a single path component
before it reaches store_dir.join()/remove_dir_all, closing a path-traversal
gap in an MCP tool surface reachable by any connected agent.
…t at spawn (S2)

Agent-node and blueprint configs can now carry an optional `skills: [...]`
array. At spawn time, after prompt/preset interpolation and on both
cold-start and resume paths, each pinned skill is resolved through S1's
dynamic skill store (same fetch/TTL-cache path skill_get uses) and appended
to the prompt under a `## Skill: <name>` section in listed order. A skill
that can't be resolved — or no store configured at all — degrades to a WARN
plus an inline note instead of failing the spawn. Oversized injected content
is still caught by the existing B9 stdin-transport threshold.

blueprint config merge and loop_get's node listing already pass `skills`
through untouched, verified with tests rather than reimplemented.
The UniverLab/skills catalog renamed loop-design to canopy-loop-design and
split execution-mindset's Canopy tooling into new canopy-intelligence and
canopy-sync skills, alongside new architect-mindset/canopy-capabilities
entries gated by requires = "canopy". Updates the prompt-builder guidance
text to describe the family instead of the old two-skill summary, renames
the stale loop-design references in sync_policy's tests, and adds an
explicit one-time migration that moves a machine's existing
~/.agents/skills/loop-design/ to canopy-loop-design/ before the normal
per-file sync runs — skipping the move (with a WARN) if both directories
already exist rather than guessing which one should win, so B4's
never-clobber-newer-local-content rule stays intact.
First step of the theme system: introduces Theme with classic()/default()
mirroring today's hardcoded colors (BORDER_COLOR, ACCENT, BG_SELECTED, DIM,
and the Rgb(18,18,18) background). Not yet wired into any renderer.
…r constants

Thread Theme::classic() through draw_header, draw_footer, and
draw_sidebar render paths. Replace direct uses of ACCENT, DIM,
BG_SELECTED, and BORDER_COLOR with theme.header_color, theme.dim_text,
theme.selected_bg, and theme.border_color respectively.

Purely mechanical — no visual change with Theme::classic().
Panel/dashboard/dialog files remain on the old constants (T3-T4).
Thread Theme::classic() through draw_log_panel and all panel render
paths (mod.rs, loop_live.rs, sync.rs). Replace direct uses of ACCENT,
DIM, and BORDER_COLOR with theme.header_color, theme.dim_text, and
theme.border_color respectively.

Purely mechanical — no visual change with Theme::classic().
Dialog files remain on the old constants (T4).
… constants

Thread Theme::classic() through all dialog render paths and system
dashboard. Replace direct uses of ACCENT, DIM, BORDER_COLOR, and
BG_SELECTED with theme.header_color, theme.dim_text, theme.border_color,
and theme.selected_bg respectively.

Remove the old free-standing color constants from ui/mod.rs — every
renderer now reads from Theme. Inline the actual Color values in
Theme::classic() instead of referencing the removed constants.

Purely mechanical — no visual change with Theme::classic().
Completes T2-T4 theme migration; only modern theme (T5) and
persistence (T6) remain.
…l renderers

Add Theme::modern() with borderless, background-contrast styling:
- panel_bg: Rgb(25, 25, 35)
- sidebar_bg: Rgb(18, 18, 25)
- selected_bg: Rgb(40, 40, 55)
- header_color: Rgb(160, 160, 170)
- dim_text: Rgb(90, 90, 100)
- show_borders: false

Add borders_for(theme) helper returning Borders::ALL for classic,
Borders::NONE for modern. Replace all Borders::ALL with borders_for(theme)
in sidebar, panels, and dialogs. Sidebar fills its area with sidebar_bg
for background contrast.

Add 6 tests: modern_is_borderless, modern_reproduces_spec_values,
borders_for_classic_draws_all_sides, borders_for_modern_draws_no_sides,
test_backend_classic_draws_box_glyphs_modern_does_not, and
modern_theme_drops_border_glyphs_where_classic_shows_them.

No theme selection/persistence yet (T6). Purely additive — classic
theme unchanged.
Add CanopyConfig::theme field (String, default 'classic') with serde
for forward-compat. Add Theme::resolve() mapping config string to
Theme — 'classic'/'modern' resolve to their palettes, unknown values
fall back to classic with a warning.

Add App::theme field resolved from config at startup. draw() now uses
app.theme instead of hardcoded Theme::classic(). app_methods.rs uses
self.theme for accent colors.

Setup wizard gains a theme selection step (step 2.25) with Classic/
Modern options.

Tests: config round-trips theme via TOML, old configs without theme
field default to classic, Theme::resolve handles all three cases,
wizard theme_choice_to_config_value maps labels correctly.
Implement deferred resume for paused loops via loop_schedule_continue,
allowing users to pause a loop (e.g. to stop burning quota) and have it
automatically resume at a scheduled time without manual intervention.

- Add auto_continue_at + auto_continue_action fields to Loop domain model
- Add is_auto_continue_due() that fires only when status == Paused
- Add fire_due_auto_continue_loops() scheduler branch that routes through
  loop_continue (retry_current_node/skip_next_spec) + resume_background,
  never loop_reset or fresh dispatch
- Clear stale schedules when loop leaves Paused before fire time
- Add loop_schedule_continue MCP tool with cancel support (at=null)
- Add DB migration for new columns (idempotent)
- Add 17 tests covering domain, scheduler, DB migration, and handler

Distinct from autorun_at: autorun resets-and-relaunches failed loops,
auto-continue resumes paused loops in place preserving cursor/context.
Extend `canopy clean` with `--hard` flag that deletes orphaned projects
(registered workdir no longer exists) and all their dependent rows:
interactive sessions, terminal sessions, loops (with specs/nodes/edges/
runs/ensembles), project-scoped intelligence, sync state, prompts, and
scheduled sends.

Features:
- Transactional cascade deletion per project (no partial deletes)
- Guards: skip projects with running loops or active/resumed sessions
- Interactive confirmation prompt (default: no)
- `--yes` flag bypasses prompt for scripting
- `--dry-run --hard` prints plan without deleting
- Projects with existing workdirs are never deleted
- Standalone specs and pools preserved (not project-owned)

Implementation:
- DB layer: `cascade_delete_orphan_project`, `project_hard_cascade_counts`,
  `project_hard_cascade_skip_reason`
- Domain layer: `plan_hard_cascade` (pure decision function)
- CLI layer: `run_hard_cascade` with confirmation flow
- Full test coverage (53 clean-related tests)

Depends on C1 (soft clean, commit 105108c).
Defect A — node-initiated loop_schedule_autorun now responds promptly:
- Single-statement UPDATE with WAL mode, no transaction held across await
- scheduler_notify.notify_one() wakeup is non-blocking
- loop_run/loop_reset fast-fail against running loops with explanatory errors
- Scheduling is idempotent (retry after lost ack overwrites same column)

Defect B — deterministic reset-time parsing replaces model arithmetic:
- New domain/quota_reset.rs parses 'resets 1pm (America/Bogota)' format
- Handles 12h clock (1pm, 1:10pm, 9:20pm, 2:30am), IANA timezones via chrono_tz
- Adds 2-min safety margin, validates strictly-future and ≤24h
- Midnight rollover: 2:30am at 22:40 resolves to next day
- quota_reset_message param feeds raw CLI text to parser engine-side

Regression tests:
- 10 unit tests in quota_reset.rs covering all time formats and edge cases
- node_initiated_autorun_call_during_own_dispatch_over_http: <5s response
- quota_shaped_failure_schedules_correct_autorun_instant: end-to-end path
- retried_schedule_call_is_idempotent: double-call leaves one schedule
- loop_run/loop_reset fast-fail against running loop

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant