diff --git a/.ace-retros/8ro1ts-8ro-t-0ve-0-watch/8ro1ts-8ro-t-0ve-0-watch-slice.retro.md b/.ace-retros/8ro1ts-8ro-t-0ve-0-watch/8ro1ts-8ro-t-0ve-0-watch-slice.retro.md new file mode 100644 index 0000000000..ef7e0303c4 --- /dev/null +++ b/.ace-retros/8ro1ts-8ro-t-0ve-0-watch/8ro1ts-8ro-t-0ve-0-watch-slice.retro.md @@ -0,0 +1,30 @@ +--- +id: 8ro1ts +title: 8ro-t-0ve-0-watch-slice +type: standard +tags: [ace-assign, watch, assignment] +created_at: "2026-04-25 01:13:06" +status: active +--- + +# 8ro-t-0ve-0-watch-slice + +## What Went Well +- The scoped watcher command surface, scope parsing, and direct fast tests were already in place on the branch, so task verification could focus on whether the shipped slice actually held up under package-level validation. +- The subtree flow still surfaced a real regression instead of rubber-stamping the earlier commit: package verification caught a teardown bug in `ace-assign/test/test_helper.rb` that did not show up in the narrower watcher-only tests. +- Re-running the direct watcher tests plus the full `ace-assign` package suite provided strong evidence that the slice is stable after the guard fix. + +## What Could Be Improved +- Assignment child steps advanced underneath the driver multiple times, which caused reports to land on the wrong step numbers and made the subtree harder to audit. +- The initial work step report was used as a planning artifact because the subtree state changed between status checks; that is a process bug even though the underlying code was already shipped. +- The pre-commit review fallback only linted files and did not preserve a cleaner distinction between review evidence and later verification evidence. + +## Key Learnings +- In forked assignment subtrees, the driver has to re-check status immediately before every `finish` call; otherwise queued child steps can auto-advance and consume the wrong report. +- Package-wide verification remains necessary even when the task-specific test surface passes. The watcher slice looked complete in direct tests, but `ace-test all --profile 6` exposed a regression in shared test infrastructure. +- Guarding teardown/setup assumptions in shared test helpers is low-cost and prevents broad false-negative package failures. + +## Action Items +- Tighten `as-assign-drive` usage discipline so scoped drivers re-read active child state immediately before writing reports in long-running subtrees. +- Keep package-level verification in the subtree even when a task appears isolated to one command/test area. +- Preserve the `(@original_env || {})` guard in `ace-assign/test/test_helper.rb` and treat shared test helper edits as full-package verification triggers. diff --git a/.ace-retros/8ro24k-8rot0ve2-watch-continuation/8ro24k-8rot0ve2-watch-continuation.retro.md b/.ace-retros/8ro24k-8rot0ve2-watch-continuation/8ro24k-8rot0ve2-watch-continuation.retro.md new file mode 100644 index 0000000000..c342c85c9e --- /dev/null +++ b/.ace-retros/8ro24k-8rot0ve2-watch-continuation/8ro24k-8rot0ve2-watch-continuation.retro.md @@ -0,0 +1,25 @@ +--- +id: 8ro24k +title: 8ro.t.0ve.2 watch continuation +type: standard +tags: [ace-assign, watch, recovery] +created_at: "2026-04-25 01:25:04" +status: active +--- + +# 8ro.t.0ve.2 watch continuation + +## What Went Well +- Extended `ace-assign watch` without replacing the scoped-target shell from the prior sibling task, so the new wait/recovery loop stayed aligned with the already-shipped public command surface. +- Added direct fast coverage for the new behavioral risks: live wait, stale-session recovery, `Errno::EPERM` as alive, sequential multi-root continuation, and scoped non-widening. +- Package-level verification exposed environment leakage from the forked assignment shell before release, which let the subtree document the clean-env verification path instead of shipping ambiguous test evidence. + +## What Could Be Improved +- Package verification inside a scoped `/as-assign-drive` session inherits `ACE_ASSIGN_*` variables that can contaminate unrelated tmux/launcher tests; the verification workflow should clear fork-only env by default before running package suites. +- The `watch` implementation reused assignment-in-condition patterns that lint flagged late in the review step; keeping lint in the implementation loop earlier would have avoided two small follow-up style commits. +- The release step still arrived through the compatibility `wfi://release/publish` shim, which adds one extra read/translation hop during subtree closeout. + +## Action Items +- Update assignment verification guidance or helpers so package-level test runs inside forked subtree sessions start from a neutral `ACE_ASSIGN_*` environment. +- Keep `ace-lint` in the implementation loop for command-layer Ruby changes before the pre-commit-review step to reduce style-only follow-up commits. +- Consider retiring assignment preset references to `wfi://release/publish` in favor of `wfi://release/local` directly so release steps expose the current workflow without the compatibility shim. diff --git a/.ace-retros/8roifd-8ro-t-0ve-1-watch/8roifd-8ro-t-0ve-1-watch-startup-retained.retro.md b/.ace-retros/8roifd-8ro-t-0ve-1-watch/8roifd-8ro-t-0ve-1-watch-startup-retained.retro.md new file mode 100644 index 0000000000..e2795fb45c --- /dev/null +++ b/.ace-retros/8roifd-8ro-t-0ve-1-watch/8roifd-8ro-t-0ve-1-watch-startup-retained.retro.md @@ -0,0 +1,25 @@ +--- +id: 8roifd +title: 8ro-t-0ve-1-watch-startup-retained-e2e +type: standard +tags: [] +created_at: "2026-04-25 12:17:05" +status: active +--- + +# 8ro-t-0ve-1-watch-startup-retained-e2e + +## What Went Well +- The stalled subtree was recoverable from assignment reports and retained E2E artifacts without replaying the whole branch history. +- A focused fast regression around `AssignmentExecutor#start` exposed the real startup bug quickly: mapped `sub_steps` overrides were preserved by dynamic batch insertion but dropped during initial YAML assignment creation. +- Package verification was reliable once the scoped worker's live env (`ACE_ASSIGN_DEFAULT_TARGET`, `TMUX`, `ACE_ASSIGN_FORK_WINDOW`) was removed from the test process. + +## What Could Be Improved +- Scoped worker environments should not leak assignment or tmux targeting variables into package/E2E verification runs; that pollution produced false failures unrelated to the task. +- Manual fixture reproductions wrote generated `jobs/` artifacts back into the repo fixture tree, which had to be cleaned before release prep. +- The retained E2E harness still mixes real product failures with environment/runtime failures (missing tmux runtime, stale local fixture copies), making TC-level diagnosis slower than necessary. + +## Action Items +- Add an explicit clean-env helper or workflow note for subtree verification commands that must ignore worker-scoped assignment/tmux env vars. +- Keep generated retained-fixture job outputs outside tracked fixture directories so local reproductions cannot masquerade as source edits. +- Follow up on the retained watch E2E harness so sandbox-local fixture generation stays aligned with the repo fixtures and current runner guidance. diff --git a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/0-implement-scoped-watch-targeting-and/8ro.t.0ve.0-implement-scoped-watch-targeting-and.s.md b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/0-implement-scoped-watch-targeting-and/8ro.t.0ve.0-implement-scoped-watch-targeting-and.s.md index 94b64b4cd9..2626f0bdd3 100644 --- a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/0-implement-scoped-watch-targeting-and/8ro.t.0ve.0-implement-scoped-watch-targeting-and.s.md +++ b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/0-implement-scoped-watch-targeting-and/8ro.t.0ve.0-implement-scoped-watch-targeting-and.s.md @@ -1,6 +1,6 @@ --- id: 8ro.t.0ve.0 -status: pending +status: done priority: medium created_at: "2026-04-25 00:34:53" estimate: TBD @@ -47,14 +47,17 @@ ace-assign watch --assignment 8abcd1 --poll-interval 60 ``` - CLI options in v1: + - `--assignment ` - `--root ` - `--poll-interval ` - `--quiet` - `--debug` + - Startup summary reports assignment id, optional subtree root, and effective poll interval. - Stop summary names the remaining inline/manual boundary when fork work is exhausted. - Error output is explicit and non-zero for: + - conflicting `--root` versus scoped `@` - non-fork root target - invalid or non-positive poll interval @@ -62,11 +65,11 @@ ace-assign watch --assignment 8abcd1 --poll-interval 60 ### Success Criteria -- [ ] `ace-assign watch` is registered in `ace-assign/lib/ace/assign/cli.rb`. -- [ ] Scoped-target parsing is deterministic and rejects conflicting root forms. -- [ ] Terminal scoped subtrees exit successfully without relaunching. -- [ ] Manual-tail stop behavior is implemented for watched scopes with no remaining fork work. -- [ ] Invalid-target and invalid-poll-interval paths fail non-zero. +- [x] `ace-assign watch` is registered in `ace-assign/lib/ace/assign/cli.rb`. +- [x] Scoped-target parsing is deterministic and rejects conflicting root forms. +- [x] Terminal scoped subtrees exit successfully without relaunching. +- [x] Manual-tail stop behavior is implemented for watched scopes with no remaining fork work. +- [x] Invalid-target and invalid-poll-interval paths fail non-zero. ## Vertical Slice Decomposition (Task/Subtask Model) diff --git a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/1-implement-watcher-verification-retained-e2e/8ro.t.0ve.1-implement-watcher-verification-retained-e2e.s.md b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/1-implement-watcher-verification-retained-e2e/8ro.t.0ve.1-implement-watcher-verification-retained-e2e.s.md index cbf14d1134..ff7709c2f1 100644 --- a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/1-implement-watcher-verification-retained-e2e/8ro.t.0ve.1-implement-watcher-verification-retained-e2e.s.md +++ b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/1-implement-watcher-verification-retained-e2e/8ro.t.0ve.1-implement-watcher-verification-retained-e2e.s.md @@ -1,6 +1,6 @@ --- id: 8ro.t.0ve.1 -status: pending +status: done priority: medium created_at: "2026-04-25 00:34:53" estimate: TBD diff --git a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/2-implement-unscoped-continuation-wait-and/8ro.t.0ve.2-implement-unscoped-continuation-wait-and.s.md b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/2-implement-unscoped-continuation-wait-and/8ro.t.0ve.2-implement-unscoped-continuation-wait-and.s.md index 10cad51e7a..cc3ff0663e 100644 --- a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/2-implement-unscoped-continuation-wait-and/8ro.t.0ve.2-implement-unscoped-continuation-wait-and.s.md +++ b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/2-implement-unscoped-continuation-wait-and/8ro.t.0ve.2-implement-unscoped-continuation-wait-and.s.md @@ -1,6 +1,6 @@ --- id: 8ro.t.0ve.2 -status: pending +status: in-progress priority: medium created_at: "2026-04-25 00:34:53" estimate: TBD @@ -55,11 +55,11 @@ Telemetry rules: ### Success Criteria -- [ ] Whole-assignment watch can continue across multiple fork roots in sequence. -- [ ] Active-live telemetry causes wait instead of duplicate relaunch. -- [ ] Lost-session state triggers recovery from assignment state rather than terminal-handle dependence. -- [ ] `Errno::EPERM` is treated as alive. -- [ ] Scoped targets still respect the scoped boundary while using wait/recovery logic. +- [x] Whole-assignment watch can continue across multiple fork roots in sequence. +- [x] Active-live telemetry causes wait instead of duplicate relaunch. +- [x] Lost-session state triggers recovery from assignment state rather than terminal-handle dependence. +- [x] `Errno::EPERM` is treated as alive. +- [x] Scoped targets still respect the scoped boundary while using wait/recovery logic. ## Vertical Slice Decomposition (Task/Subtask Model) diff --git a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/ux/usage.md b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/ux/usage.md index b2b6afd9e0..0dff5ad501 100644 --- a/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/ux/usage.md +++ b/.ace-tasks/8ro.t.0ve-implement-deterministic-watch-continuation-for/ux/usage.md @@ -85,6 +85,7 @@ TC-004-watch-recovers-after-interruption - `TC-003` records raw artifacts proving the watcher advanced across multiple fork roots and stopped at an inline/manual tail. - `TC-004` records raw artifacts proving the watcher resumed from assignment state after the original parent/session disappeared. - The verifier uses raw captures under `results/tc/03/` and `results/tc/04/` as primary evidence. +- The retained runner/verifier bundle order expands from 2 goals to 4 goals without creating a watcher-only suite. ## Notes for Implementer diff --git a/.ace/review/presets/code-shine.yml b/.ace/review/presets/code-shine.yml index 996e16c755..5ed68b44f1 100644 --- a/.ace/review/presets/code-shine.yml +++ b/.ace/review/presets/code-shine.yml @@ -41,5 +41,5 @@ bundle: "project" models: - role:review-codex - - role:review-geminie + - role:review-gemini # - role:review-claude diff --git a/CHANGELOG.md b/CHANGELOG.md index f45d30438f..1af5c310a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,18 @@ All notable changes to this project will be documented in this file. - **ace-task**: Removed spike as an active task type from drafting/review guidance. Drafts now create real child tasks for each original intention, and `as-task-review` is responsible for deep analysis, draft reshaping, and dependency repair for uncertain task families. - **ace-task v0.36.6**: redesigned spike-task workflow around explicit parent-goal evaluation, in-folder post-spike task-tree rewrites, later review handoff, and same-parent follow-up subtasks for any out-of-folder drift. +- **ace-assign v0.57.0**: Extended the public `ace-assign watch` flow with live fork waiting, assignment-state recovery after lost session state, and sequential continuation across pending fork roots while preserving scoped subtree boundaries. ### Fixed +- **ace-review v0.53.7**: Corrected the `code-shine` review preset to use the shipped `review-gemini` role so polish review runs no longer fail on the misspelled Gemini alias. +- **ace-assign v0.57.5**: Preserved mapped `sub_steps` overrides for explicitly numbered top-level roots so generated child steps keep their declared fork context, provider, and workflow-backed instructions during assignment startup. +- **ace-assign v0.57.4**: Hardened scoped `ace-assign watch` continuation so leaf fork roots are recovered correctly and tmux-backed live subtrees are not relaunched while still running. +- **ace-assign v0.57.3**: Restored dependency-ordered batch compatibility for task-like objects that only expose `status` and `subtasks`, so `ace-overseer` launcher flows no longer crash when expanding explicit task refs. +- **ace-assign v0.57.2**: Preserved mapped `sub_steps` overrides during `ace-assign create --yaml`, keeping nested fork recovery/watch fixtures materialized as real fork children instead of flattening override hashes into plain child names. - **ace-task v0.36.3**: Parent-task auto-close now follows the same `TaskManager#update` path as explicit closes, so linked GitHub issues close consistently when child tasks move to archive or are manually closed. - **ace-tmux v0.17.1**: Clarified the shipped runtime-inspection contract so docs consistently treat `ace-tmux list` as the read-side baseline and no longer reference the stale `ace-tmux state` surface. +- **ace-assign v0.57.1**: Fixed `work-on-task` batch expansion so sibling task execution respects in-batch dependencies and fails clearly on cyclic dependency sets instead of trusting raw child order. +- **ace-task v0.36.8**: Fixed parent subtask loading and tree display to use dependency-safe sibling ordering, and tightened draft/review guidance so executable child order is encoded explicitly. ### Changed - **ace-task v0.36.4**: Tightened spike draft/work/review guidance so spike tasks must declare a completion contract, synchronize affected task/doc artifacts, and rerun parent `as-task-review` before they can be considered complete. @@ -20,7 +28,10 @@ All notable changes to this project will be documented in this file. - **ace-assign v0.55.0**: Added `ace-assign fork-run --callback` for tmux-backed parent/child agent flows so the forked child can send one final status sentence back to the origin pane with `ace-tmux send` while the parent drive session stays idle until that callback arrives. ### Technical +- **ace-assign v0.57.5**: Moved watch polling and recovery state handling into a dedicated runtime helper, and added fast regression coverage for explicit numbered-root overrides plus the extracted watch runtime path. +- **ace-assign v0.57.4**: Synced retained watch fixture paths, expanded shared fork-environment cleanup in tests, and added direct regression coverage for scoped leaf recovery plus tmux liveness detection. - **ace-assign v0.55.0**: Persisted callback-pane metadata in fork tmux session files, exported `ACE_ASSIGN_CALLBACK_PANE` into tmux fork launches, and updated `/as-assign-drive` to document callback-mode resume and assignment-state recovery when the callback is missing on re-entry. +- Dependency-following patch release after the `ace-assign v0.57.0` line update: `ace-overseer v0.15.5`. ### Fixed - **ace-assign v0.54.8**: Corrected tmux fork-window naming to derive from the launcher origin pane instead of the session's currently active window, and stopped tmux fork launches from stealing focus by creating fork windows and panes detached. diff --git a/Gemfile.lock b/Gemfile.lock index 060ba7fdaf..1fd97008e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ace-assign specs: - ace-assign (0.55.0) + ace-assign (0.57.5) ace-b36ts (~> 0.13) ace-llm (~> 0.34) ace-support-cli (~> 0.6) @@ -199,8 +199,8 @@ PATH PATH remote: ace-overseer specs: - ace-overseer (0.15.3) - ace-assign (~> 0.54) + ace-overseer (0.15.5) + ace-assign (~> 0.57) ace-git (~> 0.19) ace-git-worktree (~> 0.21) ace-support-cli (~> 0.6) @@ -237,7 +237,7 @@ PATH PATH remote: ace-review specs: - ace-review (0.53.6) + ace-review (0.53.7) ace-b36ts (~> 0.13) ace-bundle (~> 0.41) ace-git (~> 0.19) @@ -338,7 +338,7 @@ PATH PATH remote: ace-task specs: - ace-task (0.36.7) + ace-task (0.36.8) ace-b36ts (~> 0.13) ace-git (~> 0.19) ace-support-cli (~> 0.6) diff --git a/ace-assign/CHANGELOG.md b/ace-assign/CHANGELOG.md index c83b5fd181..4d45fc98bb 100644 --- a/ace-assign/CHANGELOG.md +++ b/ace-assign/CHANGELOG.md @@ -6,6 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [0.57.5] - 2026-04-25 + +### Fixed +- Preserved mapped `sub_steps` overrides for explicitly numbered top-level roots during assignment startup, so generated children keep their declared fork context, provider, and workflow-backed instructions. + +### Technical +- Extracted `ace-assign watch` polling, boundary evaluation, telemetry checks, and recovery flow into a dedicated `WatchRuntime`, keeping the CLI command focused on option parsing and dispatch. +- Added fast regression coverage for explicit numbered-root override preservation and the extracted watch runtime wiring. + +## [0.57.4] - 2026-04-25 + +### Fixed +- Hardened scoped `ace-assign watch` continuation so a fork-enabled leaf root remains eligible for recovery or launch instead of exiting early with a false "no fork work remains" result. +- Treated tmux session metadata as valid fork liveness telemetry so watcher recovery no longer relaunches already-running tmux-backed fork subtrees. + +### Technical +- Corrected retained `TS-ASSIGN-003` watch runner fixture references to the shipped `job.yaml` paths. +- Expanded shared test environment cleanup to clear and restore the full active fork runtime variable set, and added direct watch command coverage for scoped leaf recovery and tmux liveness handling. + +## [0.57.3] - 2026-04-25 + +### Fixed +- Preserved mapped `sub_steps` overrides during `ace-assign create --yaml` startup so nested fork children keep their declared fork context, launch mode, provider, and custom instructions instead of collapsing into plain child names. +- Restored compatibility with task-like objects that do not expose `dependencies` or `metadata`, so dependency-ordered assignment expansion no longer breaks `ace-overseer` launcher flows and similar integrations. + +### Technical +- Added regression coverage for initial assignment creation with mapped `sub_steps` children to keep retained watch-recovery fixtures materialized as real nested fork work. +- Added direct regression coverage for task-like objects that only provide `status` and `subtasks`, matching `ace-overseer` assignment launcher compatibility expectations. + +## [0.57.1] - 2026-04-25 + +### Fixed +- Dependency-sort expanded `work-on-task` task batches so sibling tasks respect in-batch dependencies instead of raw subtask folder order. +- Fail fast when the requested assignment batch contains a dependency cycle instead of materializing an invalid execution order. + +### Technical +- Added regression coverage for parent-subtask expansion ordering, explicit multi-task dependency ordering, and cyclic batch rejection. + +## [0.57.0] - 2026-04-25 + +### Changed +- Added a public `ace-assign watch` command with scoped subtree targeting, explicit startup summaries, live wait/recovery behavior for active fork roots, and sequential continuation across pending fork roots until only inline/manual work remains. + +### Technical +- Shared subtree root resolution and fork-root validation between `watch` and `fork-run`, added direct watcher fast coverage for wait/recovery/`Errno::EPERM`/scoped-boundary behavior, and hardened package verification against inherited scoped-target environment leakage from forked drive sessions. + ## [0.55.0] - 2026-04-23 ### Changed diff --git a/ace-assign/README.md b/ace-assign/README.md index 8c8b7566bc..0b3870af6a 100644 --- a/ace-assign/README.md +++ b/ace-assign/README.md @@ -50,6 +50,7 @@ Assignment verification uses deterministic commands only: 1. Define steps from a [preset](.ace-defaults/assign/presets/work-on-task.yml) or compose from the [step catalog](.ace-defaults/assign/catalog/steps/) -- steps can nest into substeps and reference workflow instructions for execution details. 2. Expand the definition into a session with explicit per-step instructions, state tracking (`pending` → `in_progress` → `done`/`failed`), and numbered hierarchy (e.g., `010`, `010.01`, `010.01.01`). 3. Drive execution with `/as-assign-drive` -- fork long-running steps to isolated agent subprocesses, advance the queue on completion, and retry or inject fix steps on failure. +4. Use `ace-assign watch` to monitor whole-assignment or scoped subtree continuation, recover from stale fork session metadata, and stop cleanly when only inline/manual work remains. ## Use Cases @@ -59,6 +60,8 @@ Assignment verification uses deterministic commands only: **Run with orchestrator and fork agents** - use `/as-assign-drive` to walk through steps, forking long-running work (implementation, review, release) to isolated agent subprocesses with configurable [execution defaults](.ace-defaults/assign/config.yml) or per-step `fork.provider` overrides. Forks can run sequentially or as parallel batches, each producing inspectable traces and session reports under `.ace-local/assign/`. +**Observe retained continuation state** - use `ace-assign watch --assignment ` for whole-assignment continuation or `ace-assign watch --assignment @` for a scoped subtree. The watcher uses assignment status as the source of truth, waits on live fork work, recovers from stale session metadata, and stops when only inline/manual work remains. See [docs/usage.md](docs/usage.md) for the full command contract. + **Recover from failure without losing history** - keep failed-step lineage intact, inject targeted retries or fix steps, and continue execution with auditable failure evidence. **Compose assignments from templates** - use `/as-assign-compose` and `/as-assign-prepare` to build assignment plans from reusable patterns, then pair with [ace-task](../ace-task) for task lifecycle, [ace-bundle](../ace-bundle) for context loading, and [ace-review](../ace-review) for quality checks. diff --git a/ace-assign/docs/usage.md b/ace-assign/docs/usage.md index f670b01b63..95811ad9de 100644 --- a/ace-assign/docs/usage.md +++ b/ace-assign/docs/usage.md @@ -260,6 +260,50 @@ Provider resolution precedence for fork execution: 3. Config `execution.provider` 4. Built-in default provider +### `ace-assign watch` + +Watch assignment or subtree continuation state using assignment status as the +source of truth. + +Options: + +- `--assignment ` +- `--root ` +- `--poll-interval ` +- `--quiet, -q` +- `--debug, -d` + +Usage: + +```bash +ace-assign watch --assignment abc123 +ace-assign watch --assignment abc123@010 +ace-assign watch --assignment abc123 --root 010 --poll-interval 60 +``` + +Behavior: + +- Scoped forms `--assignment @` and `--assignment --root ` + watch the same subtree boundary. +- Conflicting scoped forms such as `--assignment abc123@010 --root 020` fail + before the watch loop begins. +- Scoped watches never widen into later parent siblings after the watched + subtree becomes terminal. +- When active fork work still appears alive, watch waits and polls again. +- When historical telemetry is stale but assignment state remains non-terminal, + watch recovers from assignment state instead of depending on the old session. +- When no fork work remains, watch exits with either: + - a completion summary if the target is already terminal, or + - a stop summary naming the remaining inline/manual boundary. +- Failed watched scopes or assignments exit non-zero with explicit failed-work + output. + +Callback compatibility: + +- `watch` does not define a `--callback` option. +- Callback-pane behavior remains a `fork-run --callback` capability for tmux + parent/child flows. + Step-level example: ```yaml diff --git a/ace-assign/lib/ace/assign/cli.rb b/ace-assign/lib/ace/assign/cli.rb index f17d973b57..e14243bc87 100644 --- a/ace-assign/lib/ace/assign/cli.rb +++ b/ace-assign/lib/ace/assign/cli.rb @@ -51,6 +51,7 @@ require_relative "cli/commands/select" require_relative "cli/commands/fork_run" require_relative "cli/commands/fork_session" +require_relative "cli/commands/watch" module Ace module Assign @@ -72,7 +73,8 @@ module CLI ["retry", "Retry failed step"], ["list", "List all assignments"], ["select", "Select active assignment"], - ["fork-run", "Run subtree in forked process"] + ["fork-run", "Run subtree in forked process"], + ["watch", "Watch assignment or subtree continuation state"] ].freeze HELP_EXAMPLES = [ @@ -82,7 +84,9 @@ module CLI "ace-assign start # Start next workable step", "ace-assign finish --message done.md # Complete active step", "cat report.md | ace-assign finish # Complete step via stdin", - "ace-assign fork-run 010.01 # Run subtree in subprocess" + "ace-assign fork-run 010.01 # Run subtree in subprocess", + "ace-assign watch --assignment 8abcd1@010", + "ace-assign watch --assignment 8abcd1 --root 010 --poll-interval 60" ].freeze # Captured command exit code from last run @@ -130,6 +134,7 @@ def self.wrap_command(command_class) register "select", wrap_command(Commands::Select) register "fork-run", wrap_command(Commands::ForkRun) register "fork-session", wrap_command(Commands::ForkSession) + register "watch", wrap_command(Commands::Watch) # Register version command version_cmd = Ace::Support::Cli::VersionCommand.build( diff --git a/ace-assign/lib/ace/assign/cli/commands/assignment_target.rb b/ace-assign/lib/ace/assign/cli/commands/assignment_target.rb index 404d2f0b47..7316024adc 100644 --- a/ace-assign/lib/ace/assign/cli/commands/assignment_target.rb +++ b/ace-assign/lib/ace/assign/cli/commands/assignment_target.rb @@ -116,6 +116,35 @@ def fork_scope_root(state, step) state.nearest_fork_ancestor(step.number) end + def resolve_root_step(state, current, explicit_root, scoped_root) + if explicit_root && scoped_root && explicit_root.strip != scoped_root.strip + raise Error, "Conflicting subtree roots: --root #{explicit_root.strip} and scope #{scoped_root.strip}" + end + + root_ref = explicit_root&.strip + root_ref = scoped_root&.strip if root_ref.nil? || root_ref.empty? + + if root_ref && !root_ref.empty? + root = state.find_by_number(root_ref) + raise StepErrors::NotFound, "Step #{root_ref} not found in queue" unless root + + return root + end + + raise Error, "No active step. Use --root or --assignment @." unless current + + root = state.nearest_fork_ancestor(current.number) + raise Error, "Active step is not in a forked subtree. Provide --root or --assignment @." unless root + + root + end + + def ensure_root_is_fork!(root_step) + return if root_step.fork? + + raise Error, "Step #{root_step.number} is not fork-enabled (context: fork missing)." + end + def scoped_fork_metadata_step(state, step, scope, scope_root) return nil unless step diff --git a/ace-assign/lib/ace/assign/cli/commands/fork_run.rb b/ace-assign/lib/ace/assign/cli/commands/fork_run.rb index adf911d51c..4beb357ced 100644 --- a/ace-assign/lib/ace/assign/cli/commands/fork_run.rb +++ b/ace-assign/lib/ace/assign/cli/commands/fork_run.rb @@ -166,36 +166,6 @@ def build_stall_reason(last_msg) end end - def resolve_root_step(state, current, explicit_root, scoped_root) - if explicit_root && scoped_root && explicit_root.strip != scoped_root.strip - raise Error, "Conflicting subtree roots: --root #{explicit_root.strip} and scope #{scoped_root.strip}" - end - - root_ref = explicit_root&.strip - root_ref = scoped_root&.strip if root_ref.nil? || root_ref.empty? - - if root_ref && !root_ref.empty? - root = state.find_by_number(root_ref) - raise StepErrors::NotFound, "Step #{root_ref} not found in queue" unless root - - return root - end - - # Fallback for legacy behavior when no root is explicitly scoped. - raise Error, "No active step. Use --root or --assignment @." unless current - - root = state.nearest_fork_ancestor(current.number) - raise Error, "Active step is not in a forked subtree. Provide --root or --assignment @." unless root - - root - end - - def ensure_root_is_fork!(root_step) - return if root_step.fork? - - raise Error, "Step #{root_step.number} is not fork-enabled (context: fork missing)." - end - def resolved_provider_for(root_step, cli_provider) explicit = cli_provider&.to_s&.strip return explicit unless explicit.nil? || explicit.empty? diff --git a/ace-assign/lib/ace/assign/cli/commands/watch.rb b/ace-assign/lib/ace/assign/cli/commands/watch.rb new file mode 100644 index 0000000000..e7562c484b --- /dev/null +++ b/ace-assign/lib/ace/assign/cli/commands/watch.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require_relative "watch_runtime" + +module Ace + module Assign + module CLI + module Commands + # Inspect watcher scope state and report deterministic stop/failure boundaries. + class Watch < Ace::Support::Cli::Command + include Ace::Support::Cli::Base + include AssignmentTarget + + DEFAULT_POLL_INTERVAL = 300 + + desc "Watch assignment or subtree continuation state" + + option :assignment, desc: "Target specific assignment ID" + option :root, desc: "Watch a specific fork subtree root (e.g., 010.01)" + option :poll_interval, type: :integer, desc: "Polling interval in seconds" + option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output" + option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output" + + def initialize(launcher: nil, sleeper: nil, pid_probe: nil, tmux_runner: nil) + super() + @launcher = launcher + @sleeper = sleeper || ->(seconds) { sleep(seconds) } + @pid_probe = pid_probe || method(:pid_alive?) + @tmux_runner = tmux_runner || Molecules::TmuxControlSurfaceRunner.new + end + + def call(**options) + poll_interval = normalize_poll_interval(options[:poll_interval]) + target = resolve_assignment_target(options) + runtime.call( + target: target, + explicit_root: options[:root], + poll_interval: poll_interval, + quiet: options[:quiet] + ) + end + + private + + attr_reader :launcher, :sleeper, :pid_probe, :tmux_runner + + def normalize_poll_interval(raw) + value = raw.nil? ? DEFAULT_POLL_INTERVAL : raw + interval = Integer(value) + raise Error, "Poll interval must be a positive integer." unless interval.positive? + + interval + rescue ArgumentError, TypeError + raise Error, "Poll interval must be a positive integer." + end + + def pid_alive?(pid) + Process.kill(0, pid.to_i) + true + rescue Errno::EPERM + true + rescue Errno::ESRCH, RangeError, TypeError + false + end + + def runtime + @runtime ||= WatchRuntime.new( + launcher: launcher, + sleeper: sleeper, + pid_probe: pid_probe, + tmux_runner: tmux_runner + ) + end + end + end + end + end +end diff --git a/ace-assign/lib/ace/assign/cli/commands/watch_runtime.rb b/ace-assign/lib/ace/assign/cli/commands/watch_runtime.rb new file mode 100644 index 0000000000..84ddbe2c84 --- /dev/null +++ b/ace-assign/lib/ace/assign/cli/commands/watch_runtime.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require_relative "assignment_target" +require "yaml" + +module Ace + module Assign + module CLI + module Commands + # Runtime loop for assignment and scoped watch behavior. + class WatchRuntime + include AssignmentTarget + + def initialize(launcher:, sleeper:, pid_probe:, tmux_runner:) + @launcher = launcher + @sleeper = sleeper + @pid_probe = pid_probe + @tmux_runner = tmux_runner + end + + def call(target:, explicit_root:, poll_interval:, quiet:) + if scoped_watch?(target, explicit_root) + watch_scoped(target: target, explicit_root: explicit_root, poll_interval: poll_interval, quiet: quiet) + else + watch_assignment(target: target, poll_interval: poll_interval, quiet: quiet) + end + end + + private + + attr_reader :launcher, :sleeper, :pid_probe, :tmux_runner + + def scoped_watch?(target, explicit_root) + !explicit_root.to_s.strip.empty? || !target.scope.to_s.strip.empty? + end + + def watch_scoped(target:, explicit_root:, poll_interval:, quiet:) + executor = build_executor_for_target(target) + scope_ref = nil + + loop do + result = executor.status + assignment = result[:assignment] + state = result[:state] + current = result[:current] + root_step = resolve_root_step(state, current, explicit_root, target.scope) + ensure_root_is_fork!(root_step) + scope_ref ||= "#{assignment.id}@#{root_step.number}" + + print_startup(scope_ref, poll_interval, quiet: quiet) + + if state.subtree_failed?(root_step.number) + failed_refs = failed_step_refs(state.subtree_steps(root_step.number)) + raise Error, "Watched scope #{scope_ref} has failed work: #{failed_refs}" + end + + if state.subtree_complete?(root_step.number) + puts "Watch target #{scope_ref} is already complete." unless quiet + return + end + + boundary = inline_manual_boundary_for_subtree(state, root_step.number) + unless remaining_fork_work_in_subtree?(state, root_step.number) + if boundary + puts "No fork work remains in watched scope #{scope_ref}. Remaining inline/manual boundary: #{boundary.number} #{boundary.name}." unless quiet + else + puts "No fork work remains in watched scope #{scope_ref}." unless quiet + end + return + end + + if (active_fork_root = active_fork_root_for_scope(state, root_step.number)) + if fork_telemetry_alive?(assignment.cache_dir, active_fork_root) + puts "Waiting for active fork subtree #{active_fork_root.number} in watched scope #{scope_ref}." unless quiet + sleeper.call(poll_interval) + next + end + + puts "Recovering watched scope #{scope_ref} from assignment state via subtree #{active_fork_root.number}." unless quiet + launch_fork_subtree(assignment_id: assignment.id, root_number: active_fork_root.number, quiet: quiet) + next + end + + next_root = next_fork_root_for_scope(state, root_step.number) + raise Error, "Watched scope #{scope_ref} still has fork work remaining." unless next_root + + puts "Launching next fork subtree #{next_root.number} for watched scope #{scope_ref}." unless quiet + launch_fork_subtree(assignment_id: assignment.id, root_number: next_root.number, quiet: quiet) + end + end + + def watch_assignment(target:, poll_interval:, quiet:) + executor = build_executor_for_target(target) + assignment_ref = nil + + loop do + result = executor.status + assignment = result[:assignment] + state = result[:state] + assignment_ref ||= assignment.id + print_startup(assignment_ref, poll_interval, quiet: quiet) + + if state.failed.any? + raise Error, "Watched assignment #{assignment_ref} has failed work: #{failed_step_refs(state.failed)}" + end + + if state.complete? + puts "Watch target #{assignment_ref} is already complete." unless quiet + return + end + + boundary = inline_manual_boundary_for_assignment(state) + unless remaining_fork_work_in_assignment?(state) + if boundary + puts "No fork work remains in watched assignment #{assignment_ref}. Remaining inline/manual boundary: #{boundary.number} #{boundary.name}." unless quiet + else + puts "No fork work remains in watched assignment #{assignment_ref}." unless quiet + end + return + end + + if (active_fork_root = active_fork_root_for_assignment(state)) + if fork_telemetry_alive?(assignment.cache_dir, active_fork_root) + puts "Waiting for active fork subtree #{active_fork_root.number} in watched assignment #{assignment_ref}." unless quiet + sleeper.call(poll_interval) + next + end + + puts "Recovering watched assignment #{assignment_ref} from assignment state via subtree #{active_fork_root.number}." unless quiet + launch_fork_subtree(assignment_id: assignment.id, root_number: active_fork_root.number, quiet: quiet) + next + end + + next_root = next_fork_root_for_assignment(state) + raise Error, "Watched assignment #{assignment_ref} still has fork work remaining." unless next_root + + puts "Launching next fork subtree #{next_root.number} for watched assignment #{assignment_ref}." unless quiet + launch_fork_subtree(assignment_id: assignment.id, root_number: next_root.number, quiet: quiet) + end + end + + def print_startup(target_ref, poll_interval, quiet:) + return if quiet + + puts "Watching #{target_ref} (poll interval: #{poll_interval}s)" + end + + def failed_step_refs(steps) + steps.select { |step| step.status == :failed }.map { |step| "#{step.number} #{step.name}" }.join(", ") + end + + def remaining_fork_work_in_subtree?(state, root_number) + subtree_steps = state.subtree_steps(root_number) + + subtree_steps.any? do |step| + if step.number == root_number + next root_step_still_driving_subtree?(subtree_steps, root_number) + end + + step.fork? && %i[pending active].include?(step.status) + end + end + + def root_step_still_driving_subtree?(subtree_steps, root_number) + root_step = subtree_steps.find { |step| step.number == root_number } + return false unless root_step&.fork? && %i[pending active].include?(root_step.status) + + subtree_steps.all? do |step| + step.number == root_number || step.complete? + end + end + + def inline_manual_boundary_for_subtree(state, root_number) + active_boundary = state.active_in_subtree(root_number).find do |step| + step.number != root_number && !step.fork? + end + return active_boundary if active_boundary + + next_step = state.next_workable_in_subtree(root_number) + return next_step if next_step && !next_step.fork? + + nil + end + + def remaining_fork_work_in_assignment?(state) + state.steps.any? { |step| step.fork? && %i[pending active].include?(step.status) } + end + + def inline_manual_boundary_for_assignment(state) + active_boundary = state.active_steps.find { |step| !step.fork? } + return active_boundary if active_boundary + + next_step = state.next_workable + return next_step if next_step && !next_step.fork? + + nil + end + + def active_fork_root_for_scope(state, root_number) + current = state.current_in_subtree(root_number) + fork_scope_root(state, current) + end + + def next_fork_root_for_scope(state, root_number) + candidate = state.next_workable_in_subtree(root_number) + return nil unless candidate + + fork_scope_root(state, candidate) + end + + def active_fork_root_for_assignment(state) + current = state.current + fork_scope_root(state, current) + end + + def next_fork_root_for_assignment(state) + candidate = state.next_workable + return nil unless candidate + + fork_scope_root(state, candidate) + end + + def fork_telemetry_alive?(cache_dir, root_step) + tracked_pids = Array(root_step.fork_tracked_pids).dup + tracked_pids << root_step.fork_launch_pid if root_step.fork_launch_pid + pid_file = root_step.fork_pid_file.to_s.strip + tracked_pids.concat(read_pid_file_pids(pid_file)) unless pid_file.empty? + tracked_pids.map!(&:to_i) + tracked_pids.reject!(&:zero?) + tracked_pids.uniq! + + return true if tracked_pids.any? do |pid| + pid_probe.call(pid) + rescue Errno::EPERM + true + end + + tmux_telemetry_alive?(cache_dir, root_step) + end + + def read_pid_file_pids(pid_file) + return [] unless File.exist?(pid_file) + + payload = YAML.safe_load_file(pid_file) || {} + Array(payload["tracked_pids"]) + Array(payload["launch_pid"]) + rescue Psych::SyntaxError, SystemCallError + [] + end + + def tmux_telemetry_alive?(cache_dir, root_step) + session_meta = read_session_metadata(cache_dir, root_step.number) + return false unless session_meta["launch_mode"].to_s == "tmux" + + pane_target = session_meta["tmux_pane_id"].to_s.strip + return false if pane_target.empty? + + tmux_runner.capture_recent_output(pane_target: pane_target, lines: 1) + true + rescue Error + false + end + + def read_session_metadata(cache_dir, root_number) + return {} if cache_dir.to_s.strip.empty? + + session_meta_file = File.join(cache_dir, "sessions", "#{root_number}-session.yml") + return {} unless File.exist?(session_meta_file) + + YAML.safe_load_file(session_meta_file) || {} + rescue Psych::SyntaxError, SystemCallError + {} + end + + def launch_fork_subtree(assignment_id:, root_number:, quiet:) + fork_run_command = ForkRun.new(launcher: launcher) + fork_run_command.call(assignment: assignment_id, root: root_number, quiet: quiet) + end + end + end + end + end +end diff --git a/ace-assign/lib/ace/assign/organisms/assignment_executor.rb b/ace-assign/lib/ace/assign/organisms/assignment_executor.rb index c5ce44c793..8cd499c9a9 100644 --- a/ace-assign/lib/ace/assign/organisms/assignment_executor.rb +++ b/ace-assign/lib/ace/assign/organisms/assignment_executor.rb @@ -67,11 +67,12 @@ def start(config_path, parent_id: nil) raise Error, "No steps defined in config" if steps_config.empty? - # Enrich steps using declared workflow/skill assign metadata. steps_config = enrich_declared_sub_steps(steps_config) + steps_config, start_child_overrides = normalize_start_sub_step_overrides(steps_config) # Expand sub-step declarations into batch parent + child steps steps_config = expand_sub_steps(steps_config) + steps_config = apply_start_sub_step_overrides(steps_config, start_child_overrides) steps_config = materialize_skill_backed_steps(steps_config) # Create assignment @@ -1517,6 +1518,25 @@ def prepare_canonical_batch_input(step_config, location:) [canonical, overrides] end + def normalize_start_sub_step_overrides(steps_config) + overrides_by_root = {} + + normalized = steps_config.each_with_index.map do |step, index| + raw_sub_steps = step["sub_steps"] || step["sub-steps"] + descriptors = parse_canonical_sub_step_descriptors(raw_sub_steps, location: "steps[#{index}].sub_steps") + next step if descriptors.nil? || descriptors[:overrides].empty? + + updated = step.dup + updated["sub_steps"] = descriptors[:names] + updated.delete("sub-steps") + root_number = step["number"] || Atoms::NumberGenerator.from_index(index) + overrides_by_root[root_number] = descriptors[:overrides] + updated + end + + [normalized, overrides_by_root] + end + def parse_canonical_sub_step_descriptors(raw_sub_steps, location:) return {names: nil, overrides: {}} if raw_sub_steps.nil? return nil unless raw_sub_steps.is_a?(Array) @@ -1545,6 +1565,32 @@ def parse_canonical_sub_step_descriptors(raw_sub_steps, location:) {names: names, overrides: overrides} end + def apply_start_sub_step_overrides(expanded_steps, overrides_by_root) + return expanded_steps if overrides_by_root.empty? + + merged = expanded_steps.map(&:dup) + index_by_number = {} + merged.each_with_index { |step, idx| index_by_number[step["number"]] = idx } + + overrides_by_root.each do |root_number, overrides| + children = merged + .select { |step| step["parent"] == root_number } + .sort_by { |step| step["number"].to_s } + + children.each_with_index do |child, child_index| + override = overrides[child_index] + next unless override + + merged[index_by_number.fetch(child["number"])] = child.merge(override).merge( + "number" => child["number"], + "parent" => child["parent"] + ) + end + end + + merged + end + def apply_canonical_child_overrides(expanded_steps, overrides) return expanded_steps if overrides.empty? diff --git a/ace-assign/lib/ace/assign/organisms/task_assignment_creator.rb b/ace-assign/lib/ace/assign/organisms/task_assignment_creator.rb index 225f229e64..ac04c8b29b 100644 --- a/ace-assign/lib/ace/assign/organisms/task_assignment_creator.rb +++ b/ace-assign/lib/ace/assign/organisms/task_assignment_creator.rb @@ -115,21 +115,51 @@ def guard_multi_task_preset!(preset_name, preset, input_count) end def expand_task_refs_in_order(resolved_refs) - resolved_refs.flat_map do |entry| + expanded_entries = resolved_refs.flat_map do |entry| ref = entry[:ref] task = entry[:task] if entry[:is_subtask] - [ref] + [{id: ref, task: task}] elsif task.respond_to?(:subtasks) && task.subtasks&.any? active_subtasks = task.subtasks .reject { |st| Ace::Task::Atoms::TaskValidationRules.terminal_status?(st.status.to_s) } - .map(&:id) - active_subtasks.empty? ? [ref] : active_subtasks + active_subtasks.empty? ? [{id: ref, task: task}] : active_subtasks.map { |subtask| {id: subtask.id, task: subtask} } else - [ref] + [{id: ref, task: task}] end end + + sort_expanded_entries(expanded_entries).map { |entry| entry[:id] } + rescue Ace::Task::Molecules::SiblingTaskSorter::CycleError => e + raise Ace::Support::Cli::Error, + "Cannot create dependency-ordered assignment: cyclic dependencies in requested batch (#{e.task_ids.join(', ')})" + end + + def sort_expanded_entries(entries) + synthetic_tasks = entries.map do |entry| + task = entry[:task] + Ace::Task::Models::Task.new( + id: entry[:id], + dependencies: Array(task_dependencies(task)), + metadata: task_metadata(task) + ) + end + sorted_tasks = Ace::Task::Molecules::SiblingTaskSorter.sort(synthetic_tasks, raise_on_cycle: true) + entries_by_id = entries.to_h { |entry| [entry[:id], entry] } + sorted_tasks.map { |task| entries_by_id.fetch(task.id) } + end + + def task_dependencies(task) + return [] unless task&.respond_to?(:dependencies) + + task.dependencies + end + + def task_metadata(task) + return {} unless task&.respond_to?(:metadata) + + task.metadata || {} end def build_parameters(preset, primary_ref, task_refs) diff --git a/ace-assign/lib/ace/assign/version.rb b/ace-assign/lib/ace/assign/version.rb index e5cb48fe9f..9bb5efde7b 100644 --- a/ace-assign/lib/ace/assign/version.rb +++ b/ace-assign/lib/ace/assign/version.rb @@ -2,6 +2,6 @@ module Ace module Assign - VERSION = '0.55.0' + VERSION = '0.57.5' end end diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-003-watch-sequential-continuation.runner.md b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-003-watch-sequential-continuation.runner.md new file mode 100644 index 0000000000..97378e5d5c --- /dev/null +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-003-watch-sequential-continuation.runner.md @@ -0,0 +1,42 @@ +# Goal 3 — Watch Sequential Continuation + +## Goal + +Exercise the public `ace-assign watch --assignment ` path on a retained +operations fixture and capture raw evidence that the watcher advances across +multiple fork roots before stopping at an inline/manual boundary. + +## Workspace + +Save output to `results/tc/03/`. + +## Required Artifacts + +- `results/tc/03/create.stdout`, `.stderr`, `.exit` +- `results/tc/03/assignment-id.txt` +- `results/tc/03/status-before.stdout`, `.stderr`, `.exit` +- `results/tc/03/watch.stdout`, `.stderr`, `.exit` +- `results/tc/03/status-after.stdout`, `.stderr`, `.exit` + +## Constraints + +- Create the assignment from `fixtures/watch/job.yaml`. +- Persist the created assignment ID to `results/tc/03/assignment-id.txt`. +- Capture `ace-assign status --assignment ""` before invoking watch. +- Run `ace-assign watch --assignment ""` and capture the raw command output. +- Capture `ace-assign status --assignment ""` again after watch returns. +- Acceptable completion boundary: + - the watcher advanced across pending fork roots in order, then + - stopped cleanly because only inline/manual work remained, OR + - completed the full assignment if the fixture no longer leaves a manual tail. +- Raw watch output is primary evidence. Do not replace it with paraphrased + summaries. + +## Evidence Expectations + +- `watch.stdout` should expose watcher-visible continuation messages such as + startup, launching next fork roots, waiting/recovery if encountered, and the + final completion/stop summary. +- `status-before.stdout` should show the assignment still has pending fork work. +- `status-after.stdout` should show that the earlier fork roots are no longer + pending and that the queue moved forward coherently. diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-003-watch-sequential-continuation.verify.md b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-003-watch-sequential-continuation.verify.md new file mode 100644 index 0000000000..50eb912422 --- /dev/null +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-003-watch-sequential-continuation.verify.md @@ -0,0 +1,28 @@ +# Goal 3 — Watch Sequential Continuation Verification + +## Injected Context + +The verifier receives the `results/` directory tree and access to the sandbox path. + +## Expectations + +Validation order (impact-first): +1. Confirm sandbox/project state impact first. +2. Confirm explicit artifacts under `results/tc/03/`. +3. Use debug evidence only as fallback. + +1. **Assignment created** — `create.exit` is `0` and `assignment-id.txt` contains a non-empty assignment ID. +2. **Baseline shows runnable work** — `status-before.stdout` exists and shows pending or active watcher-relevant fork work before the watch command runs. +3. **Watch command captured** — `watch.stdout`, `watch.stderr`, and `watch.exit` all exist. +4. **Sequential continuation surfaced** — `watch.stdout` shows watcher-visible continuation across more than one fork root (for example multiple `Launching next fork subtree ...` messages, or equivalent evidence that multiple roots were handled in order). +5. **Terminal boundary is explicit** — the watcher output ends in either: + - a success/complete summary, OR + - a stop summary naming the remaining inline/manual boundary. +6. **Post-watch state is coherent** — `status-after.stdout` exists and reflects forward progress from the baseline rather than a reset or unrelated mutation. + +## Verdict + +- **PASS**: The retained suite proves whole-assignment watcher continuation with raw captures. +- **FAIL**: Missing artifacts, no multi-root continuation evidence, or incoherent post-watch state. + +Report: `PASS` or `FAIL` with evidence from `status-before.stdout`, `watch.stdout`, and `status-after.stdout`. diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-004-watch-recovers-after-interruption.runner.md b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-004-watch-recovers-after-interruption.runner.md new file mode 100644 index 0000000000..056e054807 --- /dev/null +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-004-watch-recovers-after-interruption.runner.md @@ -0,0 +1,47 @@ +# Goal 4 — Watch Recovers After Interruption + +## Goal + +Exercise the public `ace-assign watch --assignment @` recovery path +after the original fork session context disappears, and capture raw evidence +that recovery comes from assignment state without widening into later siblings. + +## Workspace + +Save output to `results/tc/04/`. + +## Required Artifacts + +- `results/tc/04/create.stdout`, `.stderr`, `.exit` +- `results/tc/04/assignment-id.txt` +- `results/tc/04/status-before.stdout`, `.stderr`, `.exit` +- `results/tc/04/watch-recover.stdout`, `.stderr`, `.exit` +- `results/tc/04/status-after.stdout`, `.stderr`, `.exit` + +## Constraints + +- Create the assignment from `fixtures/watch-recovery/job.yaml`. +- Persist the created assignment ID to `results/tc/04/assignment-id.txt`. +- Prepare the fixture so subtree `010` is the watched recovery scope and its + assignment state remains non-terminal when watch begins. +- Explicitly activate subtree `010` before invoking watch so the watcher sees an + active scoped root with nested fork work still pending inside that subtree. +- Simulate stale or disappeared prior session telemetry using the assignment's + own `.ace-local/assign//...` metadata before invoking watch. +- Capture scoped status with `ace-assign status --assignment "@010"` before + invoking the watcher. +- Run `ace-assign watch --assignment "@010"` and capture the raw command output. +- Capture scoped status again with `ace-assign status --assignment "@010"` + after watch returns. +- The watcher must remain scoped to `@010`; do not accept evidence that widens + into later parent siblings. + +## Evidence Expectations + +- `watch-recover.stdout` should show watcher-visible startup for `@010` and a + recovery message driven from assignment state. +- `watch-recover.stdout` must not rely on callback text alone as completion + proof. +- `status-after.stdout` should show subtree `010` reached a coherent later + state (complete or stopped at its inline/manual boundary) without demonstrating + unrelated later sibling execution. diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-004-watch-recovers-after-interruption.verify.md b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-004-watch-recovers-after-interruption.verify.md new file mode 100644 index 0000000000..b3e6394304 --- /dev/null +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/TC-004-watch-recovers-after-interruption.verify.md @@ -0,0 +1,26 @@ +# Goal 4 — Watch Recovers After Interruption Verification + +## Injected Context + +The verifier receives the `results/` directory tree and access to the sandbox path. + +## Expectations + +Validation order (impact-first): +1. Confirm sandbox/project state impact first. +2. Confirm explicit artifacts under `results/tc/04/`. +3. Use debug evidence only as fallback. + +1. **Assignment created** — `create.exit` is `0` and `assignment-id.txt` contains a non-empty assignment ID. +2. **Scoped baseline exists** — `status-before.stdout` exists and shows subtree-focused status for `@010` before recovery runs. +3. **Recovery command captured** — `watch-recover.stdout`, `watch-recover.stderr`, and `watch-recover.exit` all exist. +4. **Recovery surfaced from assignment state** — `watch-recover.stdout` includes explicit recovery language for the watched scope (for example `Recovering watched scope` or equivalent assignment-state recovery wording). +5. **Scoped boundary preserved** — the captured recovery/watch output and `status-after.stdout` do not show later parent siblings being watched as part of this scoped recovery case. +6. **Post-recovery state is coherent** — `status-after.stdout` reflects forward progress or scoped terminal state for `@010`. + +## Verdict + +- **PASS**: The retained suite proves scoped watcher recovery after interruption with raw captures. +- **FAIL**: Missing artifacts, no assignment-state recovery evidence, or widened scope beyond `@010`. + +Report: `PASS` or `FAIL` with evidence from `status-before.stdout`, `watch-recover.stdout`, and `status-after.stdout`. diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/fork/job.yaml b/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/fork/job.yaml index 33c4d13a43..fbed1f47c1 100644 --- a/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/fork/job.yaml +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/fork/job.yaml @@ -9,6 +9,9 @@ steps: - name: delegated-subtree context: fork + fork: + mode: headless + provider: codex:gpt-5.4-mini instructions: | Execute delegated subtree using fork-run. sub_steps: diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/watch-recovery/job.yaml b/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/watch-recovery/job.yaml new file mode 100644 index 0000000000..2e097abe22 --- /dev/null +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/watch-recovery/job.yaml @@ -0,0 +1,33 @@ +session: + name: watch-recovery-operations + description: Retained watcher interruption recovery flow + +steps: + - name: delegated-recovery + context: fork + fork: + mode: headless + provider: codex:gpt-5.4-mini + instructions: | + Resume this subtree from assignment state after telemetry goes stale. + sub_steps: + - name: nested-recovery-fork + context: fork + fork: + mode: headless + provider: codex:gpt-5.4-mini + instructions: | + Nested fork work remains inside the scoped recovery subtree. + - verify-inline + + - name: later-sibling + context: fork + fork: + mode: headless + provider: codex:gpt-5.4-mini + instructions: | + Remains outside the scoped recovery boundary. + + - name: manual-tail + instructions: | + Validate that scoped watch did not widen into later siblings. diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/watch/job.yaml b/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/watch/job.yaml new file mode 100644 index 0000000000..c067ab3edc --- /dev/null +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/fixtures/watch/job.yaml @@ -0,0 +1,29 @@ +session: + name: watch-sequential-operations + description: Retained watcher continuation flow + +steps: + - name: delegated-alpha + context: fork + fork: + mode: headless + provider: codex:gpt-5.4-mini + instructions: | + Execute the first retained watcher subtree. + sub_steps: + - onboard + - plan-task + + - name: delegated-beta + context: fork + fork: + mode: headless + provider: codex:gpt-5.4-mini + instructions: | + Execute the second retained watcher subtree. + sub_steps: + - work-on-task + + - name: manual-tail + instructions: | + Validate the queue state after watcher continuation stops. diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/runner.yml.md b/ace-assign/test/e2e/TS-ASSIGN-003-operations/runner.yml.md index 30e2741400..b0a589278b 100644 --- a/ace-assign/test/e2e/TS-ASSIGN-003-operations/runner.yml.md +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/runner.yml.md @@ -8,6 +8,8 @@ bundle: files: - ./TC-001-multi-assignment.runner.md - ./TC-002-fork-run-delegation.runner.md + - ./TC-003-watch-sequential-continuation.runner.md + - ./TC-004-watch-recovers-after-interruption.runner.md --- # E2E Test Runner: ace-assign Operations @@ -21,7 +23,7 @@ Execute each goal sequentially. ## Rules - Setup ownership belongs to `scenario.yml` and fixtures; do not re-implement setup in TC runners -- Execute each goal in order (1 through 2) +- Execute each goal in order (1 through 4) - Use only declared scenario tools (`ace-*` and explicit exceptions from `requires.tools`) - Save all artifacts to results/tc/{NN}/ directories as specified - Do not assign PASS/FAIL verdicts in runner output diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/scenario.yml b/ace-assign/test/e2e/TS-ASSIGN-003-operations/scenario.yml index cf5d0f6789..c6337f434f 100644 --- a/ace-assign/test/e2e/TS-ASSIGN-003-operations/scenario.yml +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/scenario.yml @@ -11,12 +11,16 @@ tool-under-test: ace-assign sandbox-layout: results/tc/01/: "Multi-assignment operator flow evidence" results/tc/02/: "Fork-run delegated subtree evidence" + results/tc/03/: "Watcher sequential continuation evidence" + results/tc/04/: "Watcher interruption recovery evidence" e2e-justification: > Assignment operators rely on explicit assignment targeting and scoped subtree execution. This scenario validates those user-visible operations through real - CLI journeys: working with multiple concurrent assignments and executing a - fork-enabled subtree using `fork-run` with scoped assignment syntax. + CLI journeys: working with multiple concurrent assignments, executing a + fork-enabled subtree using `fork-run` with scoped assignment syntax, and + validating retained watcher continuation and recovery behavior with raw + captures. requires: tools: [ace-assign] @@ -28,3 +32,7 @@ setup: - copy-fixtures - agent-env: PROJECT_ROOT_PATH: . + ACE_ASSIGN_DEFAULT_TARGET: "" + ACE_ASSIGN_CURRENT_ASSIGNMENT_ID: "" + ACE_ASSIGN_CURRENT_FORK_ROOT: "" + ACE_ASSIGN_LAUNCH_MODE: "" diff --git a/ace-assign/test/e2e/TS-ASSIGN-003-operations/verifier.yml.md b/ace-assign/test/e2e/TS-ASSIGN-003-operations/verifier.yml.md index 5406e1f459..38c7c55ed5 100644 --- a/ace-assign/test/e2e/TS-ASSIGN-003-operations/verifier.yml.md +++ b/ace-assign/test/e2e/TS-ASSIGN-003-operations/verifier.yml.md @@ -8,6 +8,8 @@ bundle: files: - ./TC-001-multi-assignment.verify.md - ./TC-002-fork-run-delegation.verify.md + - ./TC-003-watch-sequential-continuation.verify.md + - ./TC-004-watch-recovers-after-interruption.verify.md --- # E2E Verification: ace-assign Operations @@ -33,4 +35,4 @@ For each goal output: - **Verdict**: PASS | FAIL - **Evidence**: -Final line: **Results: X/2 passed** +Final line: **Results: X/4 passed** diff --git a/ace-assign/test/fast/commands/create_command_test.rb b/ace-assign/test/fast/commands/create_command_test.rb index 0f5c06767f..b7221dd087 100644 --- a/ace-assign/test/fast/commands/create_command_test.rb +++ b/ace-assign/test/fast/commands/create_command_test.rb @@ -12,20 +12,51 @@ def show(ref) data = @tasks[ref] return nil unless data + return data if data.respond_to?(:status) + FakeTask.new(data) end end class FakeTask - attr_reader :status, :subtasks + attr_reader :status, :subtasks, :dependencies, :metadata def initialize(data) @status = data[:status] - @subtasks = Array(data[:subtasks]).map { |entry| FakeSubtask.new(entry[:id], entry[:status]) } + @dependencies = Array(data[:dependencies]) + @metadata = data[:metadata] || {} + @subtasks = Array(data[:subtasks]).map do |entry| + FakeSubtask.new( + entry[:id], + entry[:status], + dependencies: entry[:dependencies], + metadata: entry[:metadata] + ) + end end end class FakeSubtask + attr_reader :id, :status, :dependencies, :metadata + + def initialize(id, status, dependencies: nil, metadata: nil) + @id = id + @status = status + @dependencies = Array(dependencies) + @metadata = metadata || {} + end + end + + class CompatibilityTask + attr_reader :status, :subtasks + + def initialize(status:, subtasks: nil) + @status = status + @subtasks = Array(subtasks) + end + end + + class CompatibilitySubtask attr_reader :id, :status def initialize(id, status) @@ -221,6 +252,78 @@ def test_task_assignment_creator_rejects_all_terminal_refs assert_includes error.message, "403" end + def test_task_assignment_creator_orders_parent_subtasks_by_dependencies + creator = Ace::Assign::Organisms::TaskAssignmentCreator.new( + task_manager: FakeTaskManager.new( + "500" => { + status: "pending", + subtasks: [ + {id: "500.0", status: "pending"}, + {id: "500.1", status: "pending", dependencies: ["500.2"]}, + {id: "500.2", status: "pending"} + ] + } + ), + executor: FakeExecutor.new + ) + + result = creator.call(task_refs: ["500"]) + + assert_equal %w[500.0 500.2 500.1], result[:task_refs] + end + + def test_task_assignment_creator_orders_explicit_taskrefs_by_dependencies + creator = Ace::Assign::Organisms::TaskAssignmentCreator.new( + task_manager: FakeTaskManager.new( + "601" => {status: "pending"}, + "602" => {status: "pending", dependencies: ["603"]}, + "603" => {status: "pending"} + ), + executor: FakeExecutor.new + ) + + result = creator.call(task_refs: ["601", "602", "603"]) + + assert_equal %w[601 603 602], result[:task_refs] + end + + def test_task_assignment_creator_accepts_task_like_objects_without_dependency_methods + creator = Ace::Assign::Organisms::TaskAssignmentCreator.new( + task_manager: FakeTaskManager.new( + "801" => CompatibilityTask.new( + status: "pending", + subtasks: [ + CompatibilitySubtask.new("801.01", "pending"), + CompatibilitySubtask.new("801.02", "pending") + ] + ) + ), + executor: FakeExecutor.new + ) + + result = creator.call(task_refs: ["801"]) + + assert_equal %w[801.01 801.02], result[:task_refs] + end + + def test_task_assignment_creator_rejects_cycles_in_requested_batch + creator = Ace::Assign::Organisms::TaskAssignmentCreator.new( + task_manager: FakeTaskManager.new( + "701" => {status: "pending", dependencies: ["702"]}, + "702" => {status: "pending", dependencies: ["701"]} + ), + executor: FakeExecutor.new + ) + + error = assert_raises(Ace::Support::Cli::Error) do + creator.call(task_refs: ["701", "702"]) + end + + assert_includes error.message, "cyclic dependencies" + assert_includes error.message, "701" + assert_includes error.message, "702" + end + def test_create_task_mode_creates_assignment_and_step_files_end_to_end with_temp_cache do |cache_dir| Ace::Assign.config["cache_dir"] = cache_dir diff --git a/ace-assign/test/fast/commands/watch_command_test.rb b/ace-assign/test/fast/commands/watch_command_test.rb new file mode 100644 index 0000000000..bc5b0163d2 --- /dev/null +++ b/ace-assign/test/fast/commands/watch_command_test.rb @@ -0,0 +1,496 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class WatchCommandTest < AceAssignTestCase + ResolverTarget = Ace::Assign::CLI::Commands::AssignmentTarget::Target + + class CompletingLauncher + attr_reader :calls + + def initialize(cache_base:) + @cache_base = cache_base + @calls = [] + end + + def launch(assignment_id:, fork_root:, **kwargs) + @calls << kwargs.merge(assignment_id: assignment_id, fork_root: fork_root) + manager = Ace::Assign::Molecules::AssignmentManager.new(cache_base: @cache_base) + scanner = Ace::Assign::Molecules::QueueScanner.new + writer = Ace::Assign::Molecules::StepWriter.new + assignment = manager.load(assignment_id) + state = scanner.scan(assignment.steps_dir, assignment: assignment) + + state.subtree_steps(fork_root).each do |step| + next if step.status == :done + + writer.mark_done(step.file_path, report_content: "Completed by watcher launcher", reports_dir: assignment.reports_dir) + end + end + end + + class SpyLauncher + attr_reader :calls + + def initialize + @calls = [] + end + + def launch(**kwargs) + @calls << kwargs + end + end + + class FakeTmuxRunner + attr_reader :captures + + def initialize(alive_panes: []) + @alive_panes = Array(alive_panes) + @captures = [] + end + + def capture_recent_output(pane_target:, lines:) + @captures << {pane_target: pane_target, lines: lines} + raise Ace::Assign::Error, "pane not found" unless @alive_panes.include?(pane_target) + + "fork pane is still running" + end + end + + def run_watch_command(cache_base:, launcher: nil, sleeper: nil, pid_probe: nil, tmux_runner: nil, **kwargs) + command = Ace::Assign::CLI::Commands::Watch.new( + launcher: launcher, + sleeper: sleeper, + pid_probe: pid_probe, + tmux_runner: tmux_runner + ) + with_fast_command_executor(command, cache_base: cache_base) do + command.call(**kwargs) + end + end + + def capture_watch_command(cache_base:, launcher: nil, sleeper: nil, pid_probe: nil, tmux_runner: nil, **kwargs) + capture_io do + run_watch_command( + cache_base: cache_base, + launcher: launcher, + sleeper: sleeper, + pid_probe: pid_probe, + tmux_runner: tmux_runner, + **kwargs + ) + end + end + + def load_assignment(cache_dir, assignment_id) + Ace::Assign::Molecules::AssignmentManager.new(cache_base: cache_dir).load(assignment_id) + end + + def mark_step_done(assignment, step) + Ace::Assign::Molecules::StepWriter.new.mark_done( + step.file_path, + report_content: "Done", + reports_dir: assignment.reports_dir + ) + end + + def step_for(assignment, number) + scanner = Ace::Assign::Molecules::QueueScanner.new + state = scanner.scan(assignment.steps_dir, assignment: assignment) + state.find_by_number(number) + end + + def record_fork_pid_info(step, pid:) + Ace::Assign::Molecules::StepWriter.new.record_fork_pid_info( + step.file_path, + launch_pid: pid, + tracked_pids: [pid] + ) + end + + def write_tmux_session_meta(assignment, root_number, pane: "%42") + sessions_dir = File.join(assignment.cache_dir, "sessions") + FileUtils.mkdir_p(sessions_dir) + File.write( + File.join(sessions_dir, "#{root_number}-session.yml"), + { + "launch_mode" => "tmux", + "tmux_session" => "demo", + "tmux_window" => "work-fs", + "tmux_pane_id" => pane + }.to_yaml + ) + end + + def test_watch_scoped_target_exits_success_when_subtree_already_complete + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + mark_step_done(assignment, result[:state].find_by_number("010")) + + output = capture_watch_command(cache_base: cache_dir, assignment: "#{assignment.id}@010") + + assert_includes output.first, "Watching #{assignment.id}@010 (poll interval: 300s)" + assert_includes output.first, "Watch target #{assignment.id}@010 is already complete." + + Ace::Assign.reset_config! + end + end + + def test_watch_assignment_plus_root_matches_scoped_assignment_output + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + mark_step_done(assignment, result[:state].find_by_number("010")) + + scoped_output = capture_watch_command(cache_base: cache_dir, assignment: "#{assignment.id}@010") + explicit_root_output = capture_watch_command(cache_base: cache_dir, assignment: assignment.id, root: "010") + + assert_equal scoped_output.first, explicit_root_output.first + + Ace::Assign.reset_config! + end + end + + def test_watch_rejects_conflicting_scoped_root_forms + with_temp_cache do |cache_dir| + steps = [ + {"name" => "forked-a", "instructions" => "Work", "context" => "fork"}, + {"name" => "forked-b", "instructions" => "Work", "context" => "fork"} + ] + config_path = create_test_config(cache_dir, steps: steps) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + + error = assert_raises(Ace::Support::Cli::Error) do + run_watch_command(cache_base: cache_dir, assignment: "#{result[:assignment].id}@010", root: "020") + end + + assert_includes error.message, "Conflicting subtree roots" + + Ace::Assign.reset_config! + end + end + + def test_watch_rejects_non_positive_poll_interval + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + + error = assert_raises(Ace::Support::Cli::Error) do + run_watch_command(cache_base: cache_dir, assignment: "#{result[:assignment].id}@010", poll_interval: 0) + end + + assert_includes error.message, "Poll interval must be a positive integer" + + Ace::Assign.reset_config! + end + end + + def test_watch_rejects_non_fork_root + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + + error = assert_raises(Ace::Support::Cli::Error) do + run_watch_command(cache_base: cache_dir, assignment: result[:assignment].id, root: "010") + end + + assert_includes error.message, "not fork-enabled" + + Ace::Assign.reset_config! + end + end + + def test_watch_fails_when_scoped_target_contains_failed_work + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + root = result[:state].find_by_number("010") + Ace::Assign::Molecules::StepWriter.new.mark_failed(root.file_path, error_message: "boom") + + error = assert_raises(Ace::Support::Cli::Error) do + run_watch_command(cache_base: cache_dir, assignment: "#{result[:assignment].id}@010") + end + + assert_includes error.message, "has failed work: 010 forked" + + Ace::Assign.reset_config! + end + end + + def test_watch_reports_inline_manual_tail_for_scoped_target + with_temp_cache do |cache_dir| + steps = [ + { + "name" => "forked", + "instructions" => "Watch subtree", + "context" => "fork", + "sub_steps" => ["manual-tail"] + } + ] + config_path = create_test_config(cache_dir, steps: steps) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + root = result[:state].find_by_number("010") + Ace::Assign::Molecules::StepWriter.new.mark_active(root.file_path) + + output = capture_watch_command(cache_base: cache_dir, assignment: "#{result[:assignment].id}@010") + + assert_includes output.first, "No fork work remains in watched scope #{result[:assignment].id}@010." + assert_includes output.first, "Remaining inline/manual boundary: 010.01 manual-tail." + + Ace::Assign.reset_config! + end + end + + def test_watch_scoped_leaf_root_recovers_active_root_instead_of_reporting_no_fork_work + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + root = result[:state].find_by_number("010") + Ace::Assign::Molecules::StepWriter.new.mark_active(root.file_path) + record_fork_pid_info(root, pid: 11_111) + + launcher = CompletingLauncher.new(cache_base: cache_dir) + output = capture_watch_command( + cache_base: cache_dir, + assignment: "#{assignment.id}@010", + launcher: launcher, + pid_probe: ->(_pid) { false } + ) + + assert_equal ["010"], launcher.calls.map { |call| call[:fork_root] } + assert_includes output.first, "Recovering watched scope #{assignment.id}@010 from assignment state via subtree 010." + assert_includes output.first, "Watch target #{assignment.id}@010 is already complete." + + Ace::Assign.reset_config! + end + end + + def test_watch_waits_for_live_active_fork_work_without_duplicate_relaunch + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + root = result[:state].find_by_number("010") + Ace::Assign::Molecules::StepWriter.new.mark_active(root.file_path) + record_fork_pid_info(root, pid: 12_345) + + launcher = SpyLauncher.new + waited = false + sleeper = lambda do |_seconds| + waited = true + refreshed_root = step_for(assignment, "010") + mark_step_done(assignment, refreshed_root) + end + + output = capture_watch_command( + cache_base: cache_dir, + assignment: assignment.id, + launcher: launcher, + sleeper: sleeper, + pid_probe: ->(pid) { pid == 12_345 } + ) + + assert waited + assert_empty launcher.calls + assert_includes output.first, "Waiting for active fork subtree 010 in watched assignment #{assignment.id}." + assert_includes output.first, "Watch target #{assignment.id} is already complete." + + Ace::Assign.reset_config! + end + end + + def test_watch_treats_live_tmux_pane_metadata_as_active_fork_telemetry + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork", "fork" => {"mode" => "tmux"}}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + root = result[:state].find_by_number("010") + Ace::Assign::Molecules::StepWriter.new.mark_active(root.file_path) + write_tmux_session_meta(assignment, "010", pane: "%42") + + launcher = SpyLauncher.new + tmux_runner = FakeTmuxRunner.new(alive_panes: ["%42"]) + sleeper = lambda do |_seconds| + refreshed_root = step_for(assignment, "010") + mark_step_done(assignment, refreshed_root) + end + + output = capture_watch_command( + cache_base: cache_dir, + assignment: assignment.id, + launcher: launcher, + sleeper: sleeper, + pid_probe: ->(_pid) { false }, + tmux_runner: tmux_runner + ) + + assert_empty launcher.calls + assert_equal [{pane_target: "%42", lines: 1}], tmux_runner.captures + assert_includes output.first, "Waiting for active fork subtree 010 in watched assignment #{assignment.id}." + + Ace::Assign.reset_config! + end + end + + def test_watch_uses_extracted_runtime_for_assignment_and_scoped_paths + command = Ace::Assign::CLI::Commands::Watch.new + runtime = command.send(:runtime) + + assert_instance_of Ace::Assign::CLI::Commands::WatchRuntime, runtime + end + + def test_watch_treats_eperm_pid_probe_as_alive + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + root = result[:state].find_by_number("010") + Ace::Assign::Molecules::StepWriter.new.mark_active(root.file_path) + record_fork_pid_info(root, pid: 54_321) + + launcher = SpyLauncher.new + sleeper = lambda do |_seconds| + refreshed_root = step_for(assignment, "010") + mark_step_done(assignment, refreshed_root) + end + + output = capture_watch_command( + cache_base: cache_dir, + assignment: assignment.id, + launcher: launcher, + sleeper: sleeper, + pid_probe: lambda do |pid| + raise Errno::EPERM, pid.to_s + end + ) + + assert_empty launcher.calls + assert_includes output.first, "Waiting for active fork subtree 010 in watched assignment #{assignment.id}." + + Ace::Assign.reset_config! + end + end + + def test_watch_recovers_stale_active_fork_from_assignment_state + with_temp_cache do |cache_dir| + config_path = create_test_config(cache_dir, steps: [{"name" => "forked", "instructions" => "Work", "context" => "fork"}]) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + root = result[:state].find_by_number("010") + Ace::Assign::Molecules::StepWriter.new.mark_active(root.file_path) + record_fork_pid_info(root, pid: 11_111) + + launcher = CompletingLauncher.new(cache_base: cache_dir) + output = capture_watch_command( + cache_base: cache_dir, + assignment: assignment.id, + launcher: launcher, + pid_probe: ->(_pid) { false } + ) + + assert_equal ["010"], launcher.calls.map { |call| call[:fork_root] } + assert_includes output.first, "Recovering watched assignment #{assignment.id} from assignment state via subtree 010." + assert_includes output.first, "Watch target #{assignment.id} is already complete." + + Ace::Assign.reset_config! + end + end + + def test_watch_continues_across_multiple_pending_fork_roots_in_order + with_temp_cache do |cache_dir| + steps = [ + {"name" => "fork-a", "instructions" => "Work", "context" => "fork"}, + {"name" => "fork-b", "instructions" => "Work", "context" => "fork"} + ] + config_path = create_test_config(cache_dir, steps: steps) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + launcher = CompletingLauncher.new(cache_base: cache_dir) + + output = capture_watch_command( + cache_base: cache_dir, + assignment: result[:assignment].id, + launcher: launcher, + pid_probe: ->(_pid) { false } + ) + + assert_equal %w[010 020], launcher.calls.map { |call| call[:fork_root] } + assert_includes output.first, "Launching next fork subtree 010 for watched assignment #{result[:assignment].id}." + assert_includes output.first, "Launching next fork subtree 020 for watched assignment #{result[:assignment].id}." + assert_includes output.first, "Watch target #{result[:assignment].id} is already complete." + + Ace::Assign.reset_config! + end + end + + def test_watch_scoped_target_does_not_widen_into_later_siblings + with_temp_cache do |cache_dir| + steps = [ + {"name" => "fork-a", "instructions" => "Work", "context" => "fork"}, + {"name" => "fork-b", "instructions" => "Work", "context" => "fork"} + ] + config_path = create_test_config(cache_dir, steps: steps) + Ace::Assign.config["cache_dir"] = cache_dir + + executor = build_fast_executor(cache_base: cache_dir) + result = executor.start(config_path) + assignment = load_assignment(cache_dir, result[:assignment].id) + root = result[:state].find_by_number("010") + mark_step_done(assignment, root) + + launcher = SpyLauncher.new + output = capture_watch_command( + cache_base: cache_dir, + assignment: "#{assignment.id}@010", + launcher: launcher + ) + + assert_empty launcher.calls + assert_includes output.first, "Watch target #{assignment.id}@010 is already complete." + + Ace::Assign.reset_config! + end + end +end diff --git a/ace-assign/test/fast/organisms/assignment_executor_test.rb b/ace-assign/test/fast/organisms/assignment_executor_test.rb index c2d5d13860..ad75c74178 100644 --- a/ace-assign/test/fast/organisms/assignment_executor_test.rb +++ b/ace-assign/test/fast/organisms/assignment_executor_test.rb @@ -907,6 +907,91 @@ def test_start_with_sub_steps_creates_batch_parent_and_children end end + def test_start_materializes_sub_step_overrides_for_initial_yaml + with_temp_cache do |cache_dir| + steps = [ + { + "name" => "delegated-recovery", + "context" => "fork", + "fork" => {"mode" => "headless", "provider" => "codex:gpt-5.4-mini"}, + "instructions" => "Resume this subtree from assignment state after telemetry goes stale.", + "sub_steps" => [ + { + "name" => "nested-recovery-fork", + "context" => "fork", + "fork" => {"mode" => "headless", "provider" => "codex:gpt-5.4-mini"}, + "instructions" => "Nested fork work remains inside the scoped recovery subtree." + }, + "verify-inline" + ] + } + ] + config_path = create_test_config(cache_dir, steps: steps) + + executor = Ace::Assign::Organisms::AssignmentExecutor.new(cache_base: cache_dir) + result = executor.start(config_path) + + state = result[:state] + root = state.find_by_number("010") + nested = state.find_by_number("010.01") + inline = state.find_by_number("010.02") + + assert_equal "delegated-recovery", root.name + assert_equal "fork", root.context + assert_equal "codex:gpt-5.4-mini", root.fork_provider + + assert_equal "nested-recovery-fork", nested.name + assert_equal "fork", nested.context + assert_equal "codex:gpt-5.4-mini", nested.fork_provider + assert_equal "Nested fork work remains inside the scoped recovery subtree.", nested.instructions + + assert_equal "verify-inline", inline.name + end + end + + def test_start_preserves_sub_step_overrides_for_explicit_numbered_roots + with_temp_cache do |cache_dir| + steps = [ + { + "number" => "070", + "name" => "review-cycle", + "instructions" => "Review work", + "sub_steps" => [ + { + "name" => "review-pr", + "context" => "fork", + "fork" => {"mode" => "headless", "provider" => "codex:gpt-5.4-mini"}, + "instructions" => "Run fit review." + }, + { + "name" => "apply-feedback", + "instructions" => "Apply verified feedback." + } + ] + } + ] + config_path = create_test_config(cache_dir, steps: steps) + + executor = Ace::Assign::Organisms::AssignmentExecutor.new(cache_base: cache_dir) + result = executor.start(config_path) + + state = result[:state] + root = state.find_by_number("070") + review = state.find_by_number("070.01") + apply = state.find_by_number("070.02") + + assert_equal "review-cycle", root.name + assert_equal "review-pr", review.name + assert_equal "fork", review.context + assert_equal "codex:gpt-5.4-mini", review.fork_provider + assert_includes review.instructions, "Task request: Run fit review." + assert_includes review.instructions, "# Review PR and Plan Feedback Workflow" + assert_equal "apply-feedback", apply.name + assert_includes apply.instructions, "Task request: Apply verified feedback." + assert_includes apply.instructions, "# Apply Feedback Workflow" + end + end + def test_start_with_parent_id_sets_parent_on_assignment with_temp_cache do |cache_dir| config_path = create_test_config(cache_dir) diff --git a/ace-assign/test/test_helper.rb b/ace-assign/test/test_helper.rb index 9b485e32e8..8aeb78aa9f 100644 --- a/ace-assign/test/test_helper.rb +++ b/ace-assign/test/test_helper.rb @@ -9,8 +9,21 @@ require "tmpdir" class AceAssignTestCase < AceTestCase + ASSIGN_ENV_KEYS = %w[ + ACE_ASSIGN_DEFAULT_TARGET + ACE_ASSIGN_ID + ACE_ASSIGN_CURRENT_ASSIGNMENT_ID + ACE_ASSIGN_CURRENT_FORK_ROOT + ACE_ASSIGN_LAUNCH_MODE + ACE_ASSIGN_FORK_WINDOW + ACE_ASSIGN_CALLBACK_PANE + ].freeze + def setup super + @original_env = ASSIGN_ENV_KEYS.to_h { |key| [key, ENV[key]] } + ASSIGN_ENV_KEYS.each { |key| ENV.delete(key) } + # Clean class-level tmpdir contents before each test (skip if not yet created) tmp = self.class.instance_variable_get(:@class_temp_dir) if tmp && Dir.exist?(tmp) @@ -20,6 +33,13 @@ def setup end end + def teardown + super + (@original_env || {}).each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end + # Reuse a single tmpdir per test class instead of creating/destroying per test def with_temp_cache yield self.class.class_temp_dir diff --git a/ace-overseer/CHANGELOG.md b/ace-overseer/CHANGELOG.md index a5aaa8dbae..caedefb13c 100644 --- a/ace-overseer/CHANGELOG.md +++ b/ace-overseer/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [0.15.5] - 2026-04-25 + +### Technical +- Updated the `ace-assign` runtime dependency constraint to `~> 0.57` so overseer stays aligned with the new watch-continuation assign release line. + ### Fixed - Updated the `ace-tmux` runtime dependency constraint to `~> 0.17` so overseer stays compatible with the current tmux runtime control release line. diff --git a/ace-overseer/ace-overseer.gemspec b/ace-overseer/ace-overseer.gemspec index d05be225f5..5ae35351cb 100644 --- a/ace-overseer/ace-overseer.gemspec +++ b/ace-overseer/ace-overseer.gemspec @@ -43,7 +43,7 @@ Gem::Specification.new do |spec| spec.add_dependency "ace-support-core", "~> 0.29" spec.add_dependency "ace-support-config", "~> 0.9" spec.add_dependency "ace-support-fs", "~> 0.3" - spec.add_dependency "ace-assign", "~> 0.54" + spec.add_dependency "ace-assign", "~> 0.57" spec.add_dependency "ace-git", "~> 0.19" spec.add_dependency "ace-git-worktree", "~> 0.21" spec.add_dependency "ace-task", "~> 0.31" diff --git a/ace-overseer/lib/ace/overseer/version.rb b/ace-overseer/lib/ace/overseer/version.rb index e4a1006881..e3cbaa7516 100644 --- a/ace-overseer/lib/ace/overseer/version.rb +++ b/ace-overseer/lib/ace/overseer/version.rb @@ -2,6 +2,6 @@ module Ace module Overseer - VERSION = '0.15.3' + VERSION = '0.15.5' end end diff --git a/ace-review/CHANGELOG.md b/ace-review/CHANGELOG.md index 0392cc232c..7167404a67 100644 --- a/ace-review/CHANGELOG.md +++ b/ace-review/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.53.7] - 2026-04-25 + +### Fixed +- Corrected the `code-shine` preset to use the shipped `review-gemini` role so phased polish reviews run the intended Gemini model instead of failing on a misspelled role name. + ## [0.53.6] - 2026-04-24 ### Fixed diff --git a/ace-review/lib/ace/review/version.rb b/ace-review/lib/ace/review/version.rb index fbea52a9cf..51686ff1c2 100644 --- a/ace-review/lib/ace/review/version.rb +++ b/ace-review/lib/ace/review/version.rb @@ -2,6 +2,6 @@ module Ace module Review - VERSION = '0.53.6' + VERSION = '0.53.7' end end diff --git a/ace-task/CHANGELOG.md b/ace-task/CHANGELOG.md index 512b538fca..544d6fa623 100644 --- a/ace-task/CHANGELOG.md +++ b/ace-task/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.36.8] - 2026-04-25 + +### Fixed +- Load parent subtasks in dependency-safe sibling order so `ace-task show --tree` and other parent-driven views no longer follow raw folder ordering when sibling dependencies require a different sequence. + +### Changed +- Tightened task draft and review workflow guidance so sibling execution order must be encoded as executable metadata and repaired with `position` metadata when visible family order is wrong. + +### Technical +- Added a shared sibling-task sorter plus regression coverage for dependency-ordered subtask loading. + ## [0.36.7] - 2026-04-25 ### Changed diff --git a/ace-task/handbook/workflow-instructions/task/draft.wf.md b/ace-task/handbook/workflow-instructions/task/draft.wf.md index 1bbeb27edd..d8f5d35678 100644 --- a/ace-task/handbook/workflow-instructions/task/draft.wf.md +++ b/ace-task/handbook/workflow-instructions/task/draft.wf.md @@ -182,6 +182,10 @@ When requirements are uncertain, do not create a special spike task. Draft the r 3. If ordering matters, make the first child the smallest real vertical slice that validates the riskiest path. 4. Review may later rewrite, split, reorder, skip, or add dependencies between draft subtasks. 5. Do not preserve uncertainty by inventing a dedicated research-only subtask when normal review can answer it. +6. If sibling execution order matters, encode it as executable metadata immediately: + * Add sibling `dependencies` for required ordering + * If creation order is already wrong, repair visible family order with `ace-task update --position ...` + * Do not rely on parent prose order alone to express runnable sequence 8. **Complete Behavioral Specifications** * For each created draft task, populate with: @@ -257,6 +261,7 @@ When requirements are uncertain, do not create a special spike task. Draft the r * [ ] Defaults are explicit where behavior could otherwise be ambiguous * [ ] Usage documentation created in `ux/usage.md` (when task changes any API surface) * [ ] For uncertain families, the first slice is still a real subtask rather than a dedicated spike + * [ ] When child order matters, sibling dependencies encode that order and any visible family ordering has been repaired with `position` metadata if needed 12. **Run Quality Pass (Better, Not More)** * Perform one concise quality pass before finalizing: diff --git a/ace-task/handbook/workflow-instructions/task/review.wf.md b/ace-task/handbook/workflow-instructions/task/review.wf.md index d59a20897d..d5bec2649f 100644 --- a/ace-task/handbook/workflow-instructions/task/review.wf.md +++ b/ace-task/handbook/workflow-instructions/task/review.wf.md @@ -62,6 +62,7 @@ Validate draft behavioral specifications and promote to pending when ready. This - Rewrite draft subtasks when codebase reality or shipped behavior differs from the original draft - Split draft subtasks when one child is hiding multiple real goals - Add or change normal task dependencies when ordering matters + - Repair sibling display order with `ace-task update --position ...` when the intended execution order is not reflected by the family - Mark obsolete draft subtasks `skipped` when the goal is already satisfied or no longer valid - Update the parent decomposition summary to match the repaired child set - **Web Search When Appropriate:** @@ -112,6 +113,7 @@ Validate draft behavioral specifications and promote to pending when ready. This - [ ] **Subtask Slice Clarity**: Each subtask has a distinct observable outcome and does not duplicate sibling scope - [ ] **Child Readiness Complete** (orchestrators only): Every draft child subtask has been reviewed and either promoted to `pending` or blocked with documented questions - [ ] **First Slice Is Real Work**: When ordering matters, the first slice is the smallest real capability, not a synthetic research-only task + - [ ] **Executable Child Order Encoded**: When sibling order matters, dependencies encode execution order and visible family ordering has been repaired if needed - [ ] **Slice Size Signal Present**: Each slice includes advisory size (`small|medium|large`) for planning visibility - [ ] **Decision-Complete Spec**: Implementer can execute without inventing missing behavioral decisions - [ ] **Defaults Declared**: Ambiguous/unspecified behavior has explicit defaults diff --git a/ace-task/lib/ace/task.rb b/ace-task/lib/ace/task.rb index b315d69d5c..0a831daf69 100644 --- a/ace-task/lib/ace/task.rb +++ b/ace-task/lib/ace/task.rb @@ -19,6 +19,7 @@ require_relative "task/molecules/task_config_loader" require_relative "task/molecules/task_scanner" require_relative "task/molecules/task_resolver" +require_relative "task/molecules/sibling_task_sorter" require_relative "task/molecules/task_loader" require_relative "task/molecules/task_creator" require_relative "task/molecules/subtask_creator" diff --git a/ace-task/lib/ace/task/molecules/sibling_task_sorter.rb b/ace-task/lib/ace/task/molecules/sibling_task_sorter.rb new file mode 100644 index 0000000000..eb754414dd --- /dev/null +++ b/ace-task/lib/ace/task/molecules/sibling_task_sorter.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Ace + module Task + module Molecules + # Orders sibling tasks by dependency-safe topological order. + # Position metadata influences the stable tie-break order, but never + # overrides a dependency edge. + class SiblingTaskSorter + class CycleError < StandardError + attr_reader :task_ids + + def initialize(task_ids) + @task_ids = task_ids + super("Cyclic sibling dependencies: #{task_ids.join(', ')}") + end + end + + def self.sort(tasks, raise_on_cycle: false) + return [] if tasks.nil? || tasks.empty? + + indexed = tasks.each_with_index.map do |task, index| + [task.id, {task: task, index: index}] + end.to_h + + base_order = tasks + .sort_by { |task| base_sort_key(task, indexed.fetch(task.id)[:index]) } + .each_with_index + .to_h { |task, index| [task.id, index] } + + dependents = Hash.new { |hash, key| hash[key] = [] } + indegree = tasks.each_with_object({}) { |task, hash| hash[task.id] = 0 } + + tasks.each do |task| + sibling_dependencies(task, indexed).each do |dependency_id| + dependents[dependency_id] << task.id + indegree[task.id] += 1 + end + end + + available = indegree + .select { |_task_id, degree| degree.zero? } + .keys + .sort_by { |task_id| base_order.fetch(task_id) } + + result = [] + until available.empty? + current_id = available.shift + result << indexed.fetch(current_id)[:task] + + dependents[current_id] + .sort_by { |task_id| base_order.fetch(task_id) } + .each do |dependent_id| + indegree[dependent_id] -= 1 + next unless indegree[dependent_id].zero? + + insert_index = available.bsearch_index { |task_id| base_order.fetch(task_id) > base_order.fetch(dependent_id) } + if insert_index + available.insert(insert_index, dependent_id) + else + available << dependent_id + end + end + end + + return result if result.length == tasks.length + + remaining_ids = tasks + .map(&:id) + .reject { |task_id| result.any? { |task| task.id == task_id } } + .sort_by { |task_id| base_order.fetch(task_id) } + + raise CycleError, remaining_ids if raise_on_cycle + + result + remaining_ids.map { |task_id| indexed.fetch(task_id)[:task] } + end + + def self.base_sort_key(task, original_index) + position = task.metadata&.dig("position").to_s + pinned = !position.empty? + [pinned ? 0 : 1, position, original_index] + end + private_class_method :base_sort_key + + def self.sibling_dependencies(task, indexed) + Array(task.dependencies).select { |dependency_id| indexed.key?(dependency_id) } + end + private_class_method :sibling_dependencies + end + end + end +end diff --git a/ace-task/lib/ace/task/molecules/task_loader.rb b/ace-task/lib/ace/task/molecules/task_loader.rb index a261f5648f..efc0290ca7 100644 --- a/ace-task/lib/ace/task/molecules/task_loader.rb +++ b/ace-task/lib/ace/task/molecules/task_loader.rb @@ -85,9 +85,10 @@ def decode_created_at(id) # Scan for subtask directories within the parent task directory. def load_subtask_dirs(parent_dir, parent_id, special_folder) subtask_dirs = find_subtask_dirs(parent_dir, parent_id) - subtask_dirs.filter_map do |subtask_id, subtask_dir| + loaded_subtasks = subtask_dirs.filter_map do |subtask_id, subtask_dir| load(subtask_dir, id: subtask_id, special_folder: special_folder, load_subtasks: false) end + SiblingTaskSorter.sort(loaded_subtasks) end # Find all subtask directories within a parent directory. diff --git a/ace-task/lib/ace/task/version.rb b/ace-task/lib/ace/task/version.rb index 9adb588973..2bc49ce3d6 100644 --- a/ace-task/lib/ace/task/version.rb +++ b/ace-task/lib/ace/task/version.rb @@ -2,6 +2,6 @@ module Ace module Task - VERSION = '0.36.7' + VERSION = '0.36.8' end end diff --git a/ace-task/test/fast/molecules/task_loader_test.rb b/ace-task/test/fast/molecules/task_loader_test.rb index d46aa938bb..7603a9fa6d 100644 --- a/ace-task/test/fast/molecules/task_loader_test.rb +++ b/ace-task/test/fast/molecules/task_loader_test.rb @@ -195,4 +195,57 @@ def test_subtask_loading_can_be_disabled assert_equal [], task.subtasks end + + def test_loads_subtasks_in_dependency_safe_order + task_dir = File.join(@tmpdir, "8pp.t.q7w-parent") + FileUtils.mkdir_p(task_dir) + File.write(File.join(task_dir, "8pp.t.q7w-parent.s.md"), <<~CONTENT) + --- + id: 8pp.t.q7w + status: pending + --- + + # Parent + CONTENT + + { + "0-bootstrap" => <<~CONTENT, + --- + id: 8pp.t.q7w.0 + status: pending + parent: 8pp.t.q7w + --- + + # Bootstrap + CONTENT + "1-finalize" => <<~CONTENT, + --- + id: 8pp.t.q7w.1 + status: pending + parent: 8pp.t.q7w + dependencies: [8pp.t.q7w.2] + --- + + # Finalize + CONTENT + "2-load-data" => <<~CONTENT + --- + id: 8pp.t.q7w.2 + status: pending + parent: 8pp.t.q7w + --- + + # Load Data + CONTENT + }.each do |folder, content| + subtask_dir = File.join(task_dir, folder) + FileUtils.mkdir_p(subtask_dir) + File.write(File.join(subtask_dir, "8pp.t.q7w.#{folder.split('-').first}-#{folder.split('-', 2).last}.s.md"), content) + end + + loader = Ace::Task::Molecules::TaskLoader.new + task = loader.load(task_dir, id: "8pp.t.q7w") + + assert_equal %w[8pp.t.q7w.0 8pp.t.q7w.2 8pp.t.q7w.1], task.subtasks.map(&:id) + end end