Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/decodex/src/orchestrator/run_cycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2673,6 +2673,12 @@ where
now_unix_epoch,
&mut cleared_terminal_lane_issue_ids,
)?;
cleanup_missing_orphaned_project_worktree_mappings(
&reconciliation_context,
&leases,
&worktrees,
&issues_by_id,
)?;
reconcile_orphaned_active_worktree_runs(
&reconciliation_context,
&leases,
Expand Down Expand Up @@ -2822,6 +2828,63 @@ where
context.state_store.clear_lease(lease.issue_id())
}

fn cleanup_missing_orphaned_project_worktree_mappings<T>(
context: &ProjectStateReconciliationContext<'_, T>,
leases: &[IssueLease],
worktrees: &[WorktreeMapping],
issues_by_id: &HashMap<String, TrackerIssue>,
) -> Result<()>
where
T: IssueTracker,
{
let leased_issue_ids = leases.iter().map(IssueLease::issue_id).collect::<HashSet<_>>();

for mapping in worktrees {
if leased_issue_ids.contains(mapping.issue_id())
|| mapping.provenance().is_legacy_unknown()
|| !worktree_mapping_path_is_missing(mapping.worktree_path())
{
continue;
}

let Some(issue) = issues_by_id.get(mapping.issue_id()) else {
continue;
};

if issue_has_service_ownership(context.tracker, issue, context.project.service_id())?
|| issue.has_label(context.workflow.frontmatter().tracker().needs_attention_label())
|| context
.state_store
.issue_has_active_shared_claim(context.project.service_id(), &issue.id)?
|| issue_has_running_attempt(context.state_store, &issue.id)?
|| context
.state_store
.review_handoff_marker(
context.project.service_id(),
mapping.issue_id(),
mapping.branch_name(),
)?
.is_some()
{
continue;
}

context.state_store.clear_worktree(mapping.issue_id())?;
}

Ok(())
}

fn worktree_mapping_path_is_missing(worktree_path: &Path) -> bool {
matches!(worktree_path.try_exists(), Ok(false))
}

fn issue_has_running_attempt(state_store: &StateStore, issue_id: &str) -> Result<bool> {
Ok(state_store
.latest_run_attempt_for_issue(issue_id)?
.is_some_and(|attempt| matches!(attempt.status(), "starting" | "running")))
}

fn reconcile_orphaned_active_worktree_runs<T>(
context: &ProjectStateReconciliationContext<'_, T>,
leases: &[IssueLease],
Expand Down
79 changes: 79 additions & 0 deletions apps/decodex/src/orchestrator/tests/runtime/program_reconciler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::execution_program::{
};
use crate::state::StateStore;
use crate::tracker::{self, TrackerIssue, TrackerLabel};
use crate::worktree::WorktreeManager;
use crate::orchestrator;

use crate::orchestrator::tests::{
Expand Down Expand Up @@ -158,6 +159,84 @@ fn active_conflict_domain_holds_peer_node() {
assert!(tracker.label_additions.borrow().is_empty());
}

#[test]
fn live_reconciliation_clears_missing_orphaned_mapping_before_program_selection() {
let (_temp_dir, config, workflow) = temp_project_layout();
let store = StateStore::open_in_memory().expect("state store should open");
let issue = program_reconciler_issue("issue-orphaned", "PUB-208", "Todo", &[]);
let missing_worktree_path = config.worktree_root().join(&issue.identifier);
let conflict = ExecutionConflictDomain::new(ExecutionConflictDomainKind::Module, "runtime")
.expect("conflict should build");

store
.upsert_worktree(
config.service_id(),
&issue.id,
"x/pubfi-pub-208",
&missing_worktree_path.display().to_string(),
)
.expect("orphaned mapping should persist");
store
.upsert_execution_program(
config.service_id(),
program_reconciler_program(vec![
program_reconciler_node(
"node-orphaned",
&issue,
ExecutionQueueIntent::ReadyToQueue,
)
.with_conflict_domains([conflict])
.expect("conflict should attach"),
]),
)
.expect("program should persist");

let tracker = FakeTracker::new(vec![issue.clone()]);
let blocked = orchestrator::select_execution_program_run_candidate_with_summary(
&tracker,
&config,
&workflow,
&store,
&[],
)
.expect("stale mapping should evaluate");

assert!(blocked.selected.is_none());
assert!(
store
.worktree_for_issue(&issue.id)
.expect("worktree lookup should succeed")
.is_some()
);

let worktree_manager =
WorktreeManager::new(config.service_id(), config.repo_root(), config.worktree_root());

orchestrator::reconcile_project_state(&tracker, &config, &workflow, &store, &worktree_manager)
.expect("project reconciliation should succeed");

assert!(
store
.worktree_for_issue(&issue.id)
.expect("worktree lookup should succeed")
.is_none()
);

let ready = orchestrator::select_execution_program_run_candidate_with_summary(
&tracker,
&config,
&workflow,
&store,
&[],
)
.expect("program dispatch selection should recover");
let selected = ready.selected.expect("node should be selected");

assert_eq!(ready.summary.dispatchable_nodes, 1);
assert_eq!(selected.issue.id, issue.id);
assert_eq!(selected.dispatch_mode, orchestrator::IssueDispatchMode::Program);
}

#[test]
fn needs_attention_node_is_not_selected_even_with_stale_queue_label() {
let (_temp_dir, config, workflow) = temp_project_layout();
Expand Down
11 changes: 10 additions & 1 deletion docs/spec/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,10 @@ projections.
- terminal fallback when the agent never reached the point of writing the tracker
- The service must never grant the coding agent broad tracker write access outside the currently leased issue.
- `decodex` must treat the routed Linear `description` as a generic dispatch briefing surface, not as a plugin-private authority contract. If that surface contains only a machine-readable fenced block with no surrounding briefing text, generic normal dispatch is ineligible until another explicit briefing surface exists.
- Before starting a live run, the service must reconcile stale local leases and any terminal worktree mappings against current tracker state.
- Before starting a live run, the service must reconcile stale local leases,
terminal worktree mappings, and runtime-provenance worktree mappings whose checkout
path no longer exists and has no active lease, active service label,
needs-attention label, shared claim, running attempt, or review-handoff marker.
- Generic live dispatch must not require GitHub CLI authority before the lane actually attempts PR-backed review handoff.
- Generic live dispatch must resolve `github.token_env_var` before launching the agent app-server so lane-owned `git push` and `gh pr create` commands inherit noninteractive GitHub credentials. Missing or blank GitHub credentials must fail the run through the human-required path instead of retrying or leaving a promptable lane running.
- Project configs may set `[github].command_path` to make one expected GitHub CLI binary authoritative for project-scoped GitHub operations. When it is configured, review handoff validation, retained review readback, landing inspection, GitHub comments, admin merge, merge readback, and remote branch cleanup must invoke that path instead of silently rediscovering another `gh` binary.
Expand Down Expand Up @@ -777,6 +780,12 @@ After a process restart, recent-run history, run lease ownership, retained post-
"legacy_unknown"` instead of being silently treated as a fully proven runtime-owned
lane.
- Terminal issue cleanup: once the issue reaches a terminal tracker state and no authoritative post-merge tail remains pending, remove the worktree during reconciliation or startup cleanup.
- Missing orphan cleanup: when a runtime-recorded or runtime-recovered mapping points
at a checkout path that no longer exists and no lane ownership signal remains,
reconciliation must clear the mapping before issue selection so stale local state
does not occupy Program conflict domains. `legacy_unknown` mappings are excluded
from this automatic cleanup and remain subject to the explicit legacy closeout audit
path.
- If an issue becomes non-terminal but no longer eligible while `decodex` is still preparing the lane, keep the worktree and skip execution for that pass.

## Recovery rules
Expand Down