finalize-tasks internals reference
Six non-obvious behaviours an operator may encounter when running
spec-kitty agent mission finalize-tasks. All have regression tests
under tests/specify_cli/cli/commands/ and tests/specify_cli/lanes/.
1. Explicit empty owned_files
The finalize-tasks linter normally infers owned_files from path-like
strings in the WP body. This is helpful when the author never set the
field. It surprises an operator who EXPLICITLY set owned_files: []
because the WP is a triage / planning-artifact / acceptance task that
owns no source or test files.
The fix at commit 0f4e1a383 adds a pre-check: when the frontmatter
contains the literal pattern ^owned_files:\s*\[\s*\]\s*$, inference
is skipped for that field. Authors who legitimately own no files write:
---
work_package_id: WP01
execution_mode: planning_artifact
owned_files: []
authoritative_surface: docs/triage/
---
The ownership validator still rejects this if the WP is marked
execution_mode: code_change (a code-change WP that owns no files is
suspicious by definition).
2. Lane-depth cycle safety
_compute_lane_depths walks the lane-dependency DAG and assigns each
lane a depth (parallel group). The original implementation recursed
without cycle detection: any self-loop or cycle in lane_deps blew the
recursion stack with maximum recursion depth exceeded.
The fix at commit 72ff0d723 adds an in_progress guard and a
self-reference filter. Cycle detection is best-effort:
- A lane currently being computed is treated as depth-0 when re-encountered (breakpoint).
- Self-references in
lane_depsare filtered before the recursion.
For a clean DAG (the common case) output is unchanged. For a cyclic graph, the function returns a dict with each lane present and an integer depth — but the depth value may not reflect graph reality. The proper fix for a cyclic lane graph is to validate the inputs upstream (in the WP-dependency parser), not to "solve" the cycle in the depth function.
Both fixes are locked by tests in:
tests/specify_cli/cli/commands/test_finalize_tasks_explicit_empty_owned_files.pytests/specify_cli/lanes/test_compute_lane_depths_cycle_safety.py
Removing those tests, or weakening their assertions to permit recursion, is a regression.
3. Refreshing the recorded planning commit after an amendment (#4141)
finalize-tasks freezes planning_commit_sha into lanes.json at first
run. Once execution has begun (any WP past planned), a re-finalize
PRESERVES that recorded SHA (#3311) — correct for an ownership-only
amendment, which must not silently clobber established planning provenance.
But preserve-only left no sanctioned way to advance the SHA after a
legitimate planning amendment (a WP dependency-field fix, an /spec-kitty .analyze remediation) landed mid-execution: every subsequently allocated
lane kept merging the stale planning snapshot, the move-task gates
(branch-currency / kitty-specs/ contamination / uncommitted-changes) fired
on the resulting drift, and the only in-tool path was --force on every
transition.
The fix adds an explicit, advance-only override:
spec-kitty agent mission finalize-tasks --mission <slug> --refresh-planning-commit
- With execution begun, the recorded SHA is re-pointed to the current target-branch tip, so lanes merge the amended planning state at their next allocation/reuse.
- The override is refused (exit 1,
lanes.jsonuntouched) when the recorded SHA is not an ancestor of the tip — a history rewrite or a foreign provenance SHA, not an amendment. Resolve the divergence manually instead. - Without the flag, the #3311 preserve behavior is unchanged, but a
re-finalize that detects drift (recorded SHA ≠ branch tip) now warns on
the console and names the flag; the
--jsonsuccess payload carries the decision structurally underplanning_commit(action/sha/previous_sha/branch_tip).
Locked by tests in:
tests/specify_cli/cli/commands/agent/test_issue_4141_refresh_planning_commit.pytests/specify_cli/cli/commands/agent/test_mission_finalize_phases.py(the_preserve_or_capture_planning_commit_sha/_report_planning_sha_decisionbranch tests)
Weakening the ancestor refusal, or making the refresh the default (no flag), is a regression.
4. Automatic planning-pin refresh and planning_commit_refresh
Without --refresh-planning-commit, a re-finalize after execution has begun no longer only
preserves the recorded pin. It classifies the recorded planning_commit_sha against the
target-branch tip (PinClass in src/specify_cli/lanes/planning_commit_classify.py) and
acts on the class:
| Pin class | Meaning | Result (exit code) |
|---|---|---|
ADVANCED |
The recorded object is present and an ancestor of the tip | Refreshed to the tip only when a PRIMARY planning file changed since the pin for a reason other than finalize's own earlier bookkeeping commits; otherwise preserved (0) |
ORPHANED |
Present but not an ancestor (a mid-Mission rebase, #4827) | Fails closed before any write; lanes.json untouched; use --refresh-planning-commit --allow-orphaned (1) |
FOREIGN |
The recorded object is absent from this repository | Kept, with a visible warning when the tip has advanced (0) |
INDETERMINATE |
Nothing can be inspected (tip not capturable, or no recorded SHA) | Kept, with a visible warning and reason indeterminate_tip_uncapturable; never a silent preserve (0) |
The --json success payload carries the automatic decision in an additive field:
"planning_commit_refresh": {
"status": "refreshed",
"recorded": "<previous sha>",
"candidate": "<branch tip>",
"pin_class": "advanced",
"reason": null
}
status is preserved, refreshed or kept_with_warning; reason is set only for
kept_with_warning. The field is null before execution begins and for an explicit
--refresh-planning-commit run, which keeps reporting through planning_commit (section 3).
The explicit flag and its refusals are unchanged. Code: PlanningCommitResolution and
_planning_commit_refresh_payload in
src/specify_cli/cli/commands/agent/mission_finalize_planning_pin.py.
5. Started work packages keep their lane (LANE_MEMBERSHIP_FROZEN)
A re-finalize (any run on a Mission that already has a lanes.json) keeps every
started work package on its recorded
lane, or refuses before its first status write (#5573). Design and rationale: ADR
4.x 2026-10-04-2;
the lane-id rule is explained in
Execution Lanes.
Where the check runs
_preflight_frozen_lane_membership (mission_finalize_lanes.py) runs in
finalize_tasks after the ownership gates (_run_finalize_ownership_gates) and
before the first status write (_emit_tasks_started). It is read-only: it builds
the frozen membership and dry-runs compute_lanes with it against the previous
lanes.json.
- A first finalize (no
lanes.json) reads nothing and constrains nothing. - A
single_branchMission skips the check: its one repo-root lane has nothing to move. - A
--refresh-planning-commitrun skips the check: it never recomputes lane membership. - A lane dependency cycle (
LaneDependencyCycleError,LANE_DEPENDENCY_CYCLE) is raised here too, with its existing envelope: keeping started lane-mates together can close a lane cycle that the unfrozen inputs would not have, and the real lane write would raise it only after the status writes. - Any other lane-computation failure is left to the real lane write, which reports it with its existing text.
- The lane writer (
compute_and_write_lanes) re-checks the invariant withassert_frozen_membership_honouredbefore it writeslanes.json. It also raisesLaneComputationErrorwhen it is asked to recompute over a manifest that has code lanes withfrozen=None(no evidence gathered). A caller that means "nothing has started" passesFrozenLaneMembership.empty().single_branchand a first finalize are exempt.
Evidence
| Evidence | Source | Notes |
|---|---|---|
| Started work packages | The status event log, read from the resolved status surface (_resolve_status_read_dir: the placement seam for an owned checkout, otherwise the coordination-aware status surface) |
History-based (started_wp_ids): any event into claimed, in_progress, for_review, in_review, approved or done. A status directory that exists but holds no event log means nothing started. When the status directory does not exist because the coordination worktree is not materialized (CoordinationWorktreeUnmaterialized), the log committed on the local coordination branch is read instead (_started_on_coordination_branch: git ls-tree + git cat-file on refs/heads/<coordination branch>, parsed with read_events_from_text). That read refuses (status_unreadable) when the branch is not a local head, carries no committed log, or the log is malformed. Any other missing status directory refuses: an absent surface is not an absent log. Never calls materialize() and never materializes the worktree. The read uses the local coordination branch only and never fetches, so a WP started in another clone whose coordination commits were not fetched can look unstarted; the lane-work-tip fallback still freezes a lane with recorded work in this clone. This is an accepted, documented limit. |
| Lane work tips (fallback) | recorded_tip_branches: one git for-each-ref refs/spec-kitty/lane-tip/ call |
Read on every re-finalize. A prior code lane with no history-started member and a recorded tip counts as wholly started. Every lane id of this Mission whose lane branch has a recorded tip stays reserved, including a lane that lanes.json no longer lists. A listing git cannot produce (LaneTipListingError) refuses with status_unreadable; it is never read as "no tips". lane-planning is never tip-frozen. |
| Retired work packages | The cancellation projection (present work packages minus lane inputs) | A missing started work package that was retired is leaving, not moving: no conflict. Its lane id stays reserved for that run through its binding, and on later runs for as long as its lane branch has a recorded tip. |
--validate-only
The preflight runs before the validate-only report, so --validate-only refuses
in exactly the cases a real run refuses. Its lane preview now passes the previous
lanes.json and the frozen membership to compute_lanes, so the previewed
lane_ids match what a real run writes. Before #5573 the preview minted lane ids
positionally.
Refusal envelope
Exit code 1. No status event, no lanes.json and no commit is written. The ownership
gates run before the preflight and may already have edited work package files and
tasks.md; finalize's write-scope restore puts those and meta.json back byte-identical. With --json:
{
"error": "Cannot re-finalize: started work packages would change lane. WP01 (lane-a) and WP02 (lane-b) would be merged into one lane.",
"error_code": "LANE_MEMBERSHIP_FROZEN",
"reason": "started_lanes_collapsed",
"conflicts": [
{
"reason": "started_lanes_collapsed",
"wp_ids": ["WP01", "WP02"],
"recorded_lanes": ["lane-a", "lane-b"],
"remedy": "Remove the overlap that forces WP01 and WP02 into one lane (for example, move the shared path into a new work package that depends on them, or drop it from one of them), then re-run finalize-tasks."
}
],
"next_step": "Remove the overlap that forces WP01 and WP02 into one lane (...), then re-run finalize-tasks.",
"spec_kitty_version": "..."
}
- Always present:
error,error_code,reason,conflicts,next_step, plus the standardspec_kitty_version. Finalize's existing extra keys may also appear when they apply:target_branch_override_revert_error,status_commits_not_undone, andstale_repository_root_copy(owned checkouts). conflictslists every conflict found. Each hasreason,wp_ids,recorded_lanes(both sorted) andremedy.reasonis the first conflict reason in the orderstarted_lanes_collapsed,started_wp_removed,started_wp_kind_changed,status_unreadable.next_steplists the distinct remedies, one per line, in the same order.
Without --json the console prints the error, then one line per conflict and its
remedy:
Error: Cannot re-finalize: started work packages would change lane. WP01 (lane-a) and WP02 (lane-b) would be merged into one lane.
started_lanes_collapsed: WP01 (lane-a), WP02 (lane-b)
Remedy: Remove the overlap that forces WP01 and WP02 into one lane (...), then re-run finalize-tasks.
Reasons and remedies
Code: LaneMembershipFrozenError (src/specify_cli/lanes/compute.py); conflicts and
remedy texts: src/specify_cli/lanes/frozen_membership.py.
reason |
Trigger | Remedy (as printed) |
|---|---|---|
started_lanes_collapsed |
One computed group holds started work packages recorded in two or more lanes | Remove the overlap that forces {WPs} into one lane (for example, move the shared path into a new work package that depends on them, or drop it from one of them), then re-run finalize-tasks. |
started_wp_removed |
A started work package is missing from the plan, and the cancellation projection did not retire it | Restore the task file of {WPs}. To retire a started work package instead, cancel it with spec-kitty agent tasks move-task <WP> --to canceled --mission <handle> without clearing its owned files, then re-run finalize-tasks. |
started_wp_kind_changed |
A started work package's execution_mode now puts it on the other side of lane-planning |
Restore the execution_mode of {WPs}, put the new kind of work in a new work package, then re-run finalize-tasks. |
status_unreadable |
A lanes.json exists, the coordination worktree of a lanes_with_coord or coord Mission is not materialized (CoordinationWorktreeUnmaterialized), and its committed status log cannot be read because the coordination branch is not a local head (for example it exists only on a remote) |
Materialize the coordination worktree, then re-run finalize-tasks. {next step} — the canonical CoordinationWorktreeUnmaterialized guidance: run spec-kitty doctor coordination --mission <slug> --fix (if that command cannot, materialize it manually with git -C <repo> worktree add <coordination worktree> <coordination branch>); when the coordination branch exists only on a remote, it first says to run git fetch origin <coordination branch>. |
status_unreadable |
A lanes.json exists, but the preflight cannot resolve the status surface, the status directory is missing for another reason, the local coordination branch carries no committed status log, or the event log cannot be read |
Repair the status log (spec-kitty agent status validate --mission <handle> reports the problem; spec-kitty agent status doctor checks status hygiene), then re-run finalize-tasks. Cause: {the error's message, plus its next step when the message does not already carry it} |
No remedy suggests deleting lanes.json, a force flag, git reset, git restore,
git checkout -- ., or removing worktrees or branches.
status_unreadable in practice: a malformed line in the status log that finalize's
work-package read already uses (read_wp_frontmatter) is refused earlier, by that read,
with its existing store error (Invalid JSON on line N: ...). The preflight's own
status_unreadable appears when its separate read fails: the coordination status
surface of a coordination Mission cannot be resolved (for example a deleted
coordination branch), the coordination worktree is not materialized and its branch is
not a local head, the committed log is missing or malformed, or the log cannot be
read. Each status_unreadable remedy carries its cause: the remote-only case leads
with materializing the worktree (after fetching the branch), an unreadable lane-tip
listing leads with fixing the git error, and every other case appends Cause: and
the underlying message. Its message reads Cannot re-finalize: the status log or the recorded lane work tips cannot be read, so started work cannot be determined.: it
does not claim that a lane would change. Code: _read_started_wp_ids,
_started_on_coordination_branch and _status_unreadable_error in
mission_finalize_lanes.py.
Not the planning-pin probe
The planning-pin preservation (section 3, #3311) asks "has execution begun?" through
_execution_has_begun: it is true when any work package's current lane is not
planned (blocked and canceled included), and it degrades to "not begun" when the
status surface or log cannot be read. The started-work-package predicate asks which
work packages have ever entered a working lane, and fails closed. The two answer
different questions and can disagree: a work package reset to planned is still
started, while a work package moved from planned straight to blocked makes
execution "begun" for the pin without being started. Moving the pin probe onto the
history-based predicate is Follow-up: #5702.
Locked by tests in:
tests/lanes/test_frozen_lane_membership.pyandtests/lanes/test_frozen_lane_membership_sweep.pytests/specify_cli/cli/commands/agent/test_finalize_frozen_lane_preflight.pytests/integration/test_refinalize_keeps_started_lanes.pyandtests/integration/test_refinalize_frozen_lane_refusals.py
6. Stale API-authored content is refused
A Mission authored through spec-kitty orchestrator-api carries an API-authoring
marker. For such a Mission, agent mission finalize-tasks first checks that the
specification, plan and work-package content is current and complete, exactly as
the orchestrator-api tasks verb does. Stale or unfinished content refuses with a
DESIGN_* code (for example DESIGN_PARENT_STALE or DESIGN_PREREQUISITES_FAILED)
before anything is written, and a successful finalize records the generated
work-package files as current, so native spec-kitty next --result success at
tasks then advances. --validate-only and --refresh-planning-commit skip the
check, an already-finalized Mission is not re-checked, and a Mission without the
marker behaves as before. agent tasks finalize-tasks does not run the check.
Module map
finalize-tasks is split across sibling modules in
src/specify_cli/cli/commands/agent/ (#5627). mission_finalize.py is the
facade: it keeps the command, and every other phase lives in its own module:
| Module | Owns |
|---|---|
mission_finalize.py |
The finalize_tasks command, its context (_resolve_finalize_context), the branch-setup phase (_run_finalize_branch_setup), the validation-gate and ownership-gate runners, and the helpers _meta_json_delta_is_finalize_attributable and _collect_finalize_artifacts; re-exports every name below |
mission_finalize_seams.py |
Constants, the owned-envelope ContextVar, _emit_json and the mission-routed patch seams |
mission_finalize_branch_contract.py |
Target-branch resolution, branch-contract persistence, the --target-branch override |
mission_finalize_validation.py |
Requirement-ID, dependency, requirement-mapping and issue-matrix gates |
mission_finalize_bootstrap.py |
The per-WP frontmatter bootstrap loop, ownership gates, lane-input projection, the --validate-only report, local canonical status events |
mission_finalize_planning_pin.py |
The planning-commit pin: preserve-or-capture, --refresh-planning-commit and the automatic refresh |
mission_finalize_lanes.py |
Lane computation, the frozen-lane preflight (_preflight_frozen_lane_membership, section 5) and the acceptance-matrix scaffold |
mission_finalize_commit.py |
The commit pipeline, the success report and the refusal-time rollback guards; wraps the status-surface guard with _capture_status_surface, _restore_status_surface, _restore_mission_write_scope_beside_status and _report_status_surface_leftover |
finalize_status_surface.py is not a phase module. It owns StatusSurfaceGuard
and StatusSurfaceLeftover, which mission_finalize_commit.py wraps.
mission_finalize re-exports every name the phase modules define, so
mission_finalize.<name> stays importable. How a phase module reaches a name
decides whether a patch on mission_finalize.<name> intercepts the call. The
module goes through mission_finalize at call time with a lazy in-function
import (_mf.<name>), under three rules:
- A call to a function another finalize module owns always goes through
_mf. - A call to a name that tests patch on
mission_finalizegoes through_mf. - Any other call inside a phase module is direct. To intercept it, patch the
phase module that makes the call, not
mission_finalize.
tests/specify_cli/cli/commands/agent/test_mission_finalize_phase_modules.py
pins the re-exports, the lazy import, and both routing rules (patched names and
cross-module functions are never referenced bare in a phase module). When a test
starts patching another name on mission_finalize, add it to _ROUTED_NAMES
there: the gate does not read the tests, so a newly patched name that a phase
module calls directly would not be intercepted and nothing would fail. Structural pins that read the source use tests/_support/finalize_source.py,
which reads the whole module family.