From 44af4f8a20f0c1c35c369ab3e82877cc24d2d2d0 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 15 Jun 2026 01:54:51 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Switch Program DAG scheduling to direct dispatch","authority":"manual"} --- README.md | 17 +- apps/decodex/src/cli.rs | 33 +-- apps/decodex/src/execution_program.rs | 240 ++++------------ apps/decodex/src/orchestrator.rs | 2 +- apps/decodex/src/orchestrator/daemon.rs | 19 -- .../src/orchestrator/dispatch_policy.rs | 21 +- apps/decodex/src/orchestrator/execution.rs | 4 +- .../src/orchestrator/operator_dashboard.html | 28 +- .../src/orchestrator/program_reconciler.rs | 258 ++++++----------- apps/decodex/src/orchestrator/run_cycle.rs | 14 +- apps/decodex/src/orchestrator/status.rs | 13 +- .../tests/operator/status/dashboard.rs | 5 +- .../tests/operator/status/text.rs | 93 +++--- .../tests/runtime/loop_scenarios.rs | 39 +-- .../tests/runtime/program_intake_dogfood.rs | 108 ++++--- .../tests/runtime/program_reconciler.rs | 188 +++++------- apps/decodex/src/orchestrator/types.rs | 103 ++----- apps/decodex/src/plugin_surface_tests.rs | 7 +- apps/decodex/src/program_intake.rs | 104 ++----- apps/decodex/src/state/internal.rs | 270 ++---------------- apps/decodex/src/state/models.rs | 72 ----- apps/decodex/src/state/store.rs | 46 --- apps/decodex/src/state/tests.rs | 14 +- docs/decisions/decodex-plugin-source.md | 4 +- docs/reference/operator-control-plane.md | 39 ++- docs/reference/test-suite.md | 2 +- docs/runbook/index.md | 2 +- docs/runbook/research-to-execution-loop.md | 15 +- docs/spec/loop-runtime.md | 106 ++++--- docs/spec/runtime.md | 50 ++-- docs/spec/tracker-tools.md | 21 +- plugins/decodex/skills/automation/SKILL.md | 25 +- plugins/decodex/skills/decodex/SKILL.md | 20 +- plugins/decodex/skills/planning/SKILL.md | 44 +-- 34 files changed, 632 insertions(+), 1394 deletions(-) diff --git a/README.md b/README.md index bec3d8680..b31a774df 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,10 @@ account usage probes. `decodex serve` uses hardcoded scheduler cadences: the local control-plane loop publishes snapshots every 15 seconds, and Linear-backed queue/status scans run at most every 5 minutes per project unless an operator or agent requests an explicit -scan with `POST /api/linear-scan`. +scan with `POST /api/linear-scan`. Persisted Execution Programs do not wait for that +ordinary queue-label scan: the runtime keeps the Program graph in local state, +refreshes only the mapped Linear issue facts needed for readiness, and directly +dispatches ready DAG nodes with `program` dispatch mode. `decodex research compile` is the native Decodex research/design entrypoint. It accepts minimal natural-language intake or a structured research/design JSON packet, @@ -159,21 +162,21 @@ records explicit acceptance for a stored contract; only promoted contracts may l feed issue shaping or internal Execution Program readiness. `decodex intake goal` materializes a promoted Decision Contract. `--dry-run` prints -the proposed normal Linear issues, dependencies, conflict domains, and queue plan +the proposed normal Linear issues, dependencies, conflict domains, and dispatch plan without mutating Linear or local Program Intake rows. `--apply` creates or updates the generated normal Linear issue briefs and persists the internal Execution Program plus contract/program links in runtime SQLite. Apply does not run implementation or apply -queue labels; later reconciliation owns queue-label changes. If the contract is still +queue labels; the persisted Program becomes eligible for direct graph dispatch on the +next scheduler pass. If the contract is still latent, needs a decision, or lacks issue-shaping authority, intake stops before creating executable work. `decodex intake issues` materializes a supplied batch of existing Linear issues into local Program Intake state. `--dry-run` prints the deterministic ready/held/blocked report without mutation. `--apply` persists the local Program Intake Plan, Execution -Program, issue mappings, and queue-label ownership evidence when already proven, but -it still never applies or removes service queue labels directly. Program -reconciliation owns those label changes on a later scan. `--persist` remains a legacy -alias for `--apply`. +Program, and issue mappings. It never applies or removes service queue labels; ready +mapped nodes are dispatched directly by the Program scheduler instead of being +converted into queued-label work. ### Install from Source diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 5f77d4a45..8f25119fa 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -688,8 +688,8 @@ struct IntakeIssuesCommand { /// Read tracker state and print the deterministic intake report without local persistence. #[arg(long, conflicts_with = "apply", required_unless_present = "apply")] dry_run: bool, - /// Persist local runtime Program Intake records; never mutates Linear queue labels. - #[arg(long, visible_alias = "persist", conflicts_with = "dry_run")] + /// Persist local runtime Program Intake records for direct Program dispatch. + #[arg(long, conflicts_with = "dry_run")] apply: bool, /// Emit structured JSON instead of human-readable text. #[arg(long)] @@ -1576,6 +1576,7 @@ impl From for IssueDispatchMode { fn from(value: AttemptDispatchMode) -> Self { match value { AttemptDispatchMode::Normal => Self::Normal, + AttemptDispatchMode::Program => Self::Program, AttemptDispatchMode::Retry => Self::Retry, AttemptDispatchMode::ReviewRepair => Self::ReviewRepair, AttemptDispatchMode::Closeout => Self::Closeout, @@ -1587,6 +1588,7 @@ impl From for IssueDispatchMode { #[serde(rename_all = "kebab-case")] pub(crate) enum AttemptDispatchMode { Normal, + Program, Retry, ReviewRepair, Closeout, @@ -1595,6 +1597,7 @@ impl From for AttemptDispatchMode { fn from(value: IssueDispatchMode) -> Self { match value { IssueDispatchMode::Normal => Self::Normal, + IssueDispatchMode::Program => Self::Program, IssueDispatchMode::Retry => Self::Retry, IssueDispatchMode::ReviewRepair => Self::ReviewRepair, IssueDispatchMode::Closeout => Self::Closeout, @@ -2693,32 +2696,6 @@ mod tests { )); } - #[test] - fn parses_intake_issues_legacy_persist_alias() { - let cli = Cli::parse_from([ - "decodex", - "intake", - "issues", - "--project", - "decodex", - "XY-1", - "--persist", - ]); - - assert!(matches!( - cli.command, - Command::Intake(IntakeCommand { - command: IntakeSubcommand::Issues(IntakeIssuesCommand { - project: Some(_), - dry_run: false, - apply: true, - issues, - .. - }) - }) if issues == vec![String::from("XY-1")] - )); - } - #[test] fn parses_intake_goal_apply_with_project_and_team_anchor() { let cli = Cli::parse_from([ diff --git a/apps/decodex/src/execution_program.rs b/apps/decodex/src/execution_program.rs index 679c92f75..c41e1b378 100644 --- a/apps/decodex/src/execution_program.rs +++ b/apps/decodex/src/execution_program.rs @@ -216,15 +216,15 @@ impl ExecutionProgramNodeStage { } } -/// Queue intent for one internal Execution Program node. +/// Dispatch intent for one internal Execution Program node. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum ExecutionQueueIntent { - /// The node is intentionally not ready for queueing. + /// The node is intentionally not ready for dispatch. NotReady, - /// The node is ready to receive the service queue label once mapped to a startable issue. + /// The node is ready for direct dispatch once mapped to a startable issue. ReadyToQueue, - /// The node should retain the service queue label while it remains startable. + /// The node is retained in a ready-to-dispatch position. Queued, /// The node is already active in a lane. Active, @@ -236,7 +236,7 @@ pub(crate) enum ExecutionQueueIntent { Canceled, } impl ExecutionQueueIntent { - /// Stable machine-readable queue-intent name. + /// Stable machine-readable dispatch-intent name. pub(crate) fn as_str(self) -> &'static str { match self { Self::NotReady => "not_ready", @@ -290,13 +290,13 @@ impl ExecutionConflictDomainKind { pub(crate) enum ExecutionReadinessState { /// Node is intentionally not ready yet. NotReady, - /// Node is startable and may be mapped to queue action. + /// Node is startable and may be dispatched directly. Ready, /// Node cannot start until a concrete blocker clears. Blocked, /// Node is intentionally paused. Paused, - /// Node is already active and should not retain the queue label. + /// Node is already active. Active, /// Node is terminal. Completed, @@ -325,9 +325,9 @@ pub(crate) enum ExecutionProgramNodeLifecycleState { Planned, /// Node is mapped to a normal Linear issue but is intentionally held. Mapped, - /// Node is ready to receive the service queue label. + /// Node is ready for direct dispatch. Ready, - /// Node is ready and already carries the service queue label. + /// Node was retained in a ready-to-dispatch position. Queued, /// Node already has an active lane. Active, @@ -360,23 +360,17 @@ impl ExecutionProgramNodeLifecycleState { } } -/// Queue-label action allowed for a mapped node. +/// Direct dispatch action allowed for a mapped node. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ExecutionQueueLabelAction { - /// Apply the service queue label. - Apply, - /// Retain the service queue label. - Retain, - /// Remove the service queue label. - Remove, +pub(crate) enum ExecutionDispatchAction { + /// Start this mapped node directly from the Execution Program scheduler. + Dispatch, } -impl ExecutionQueueLabelAction { - /// Stable machine-readable queue-label action. +impl ExecutionDispatchAction { + /// Stable machine-readable direct-dispatch action. pub(crate) fn as_str(self) -> &'static str { match self { - Self::Apply => "apply", - Self::Retain => "retain", - Self::Remove => "remove", + Self::Dispatch => "dispatch", } } } @@ -463,9 +457,6 @@ pub(crate) struct ExecutionLinearIssueMapping { issue_id: String, issue_identifier: String, issue_state: String, - has_queue_label: bool, - #[serde(default, skip_serializing_if = "is_false")] - queue_label_owned_by_program_reconciler: bool, has_active_label: bool, has_opt_out_label: bool, has_needs_attention_label: bool, @@ -484,8 +475,6 @@ impl ExecutionLinearIssueMapping { issue_id: issue_id.into(), issue_identifier: issue_identifier.into(), issue_state: issue_state.into(), - has_queue_label: false, - queue_label_owned_by_program_reconciler: false, has_active_label: false, has_opt_out_label: false, has_needs_attention_label: false, @@ -498,21 +487,6 @@ impl ExecutionLinearIssueMapping { Ok(mapping) } - /// Mark whether the issue currently carries the service queue label. - pub(crate) fn with_queue_label(mut self, present: bool) -> Self { - self.has_queue_label = present; - - self - } - - /// Mark that the service queue label was applied through the program reconciler. - pub(crate) fn with_program_owned_queue_label(mut self, present: bool) -> Self { - self.has_queue_label = present; - self.queue_label_owned_by_program_reconciler = present; - - self - } - /// Mark whether the issue currently carries the service active label. pub(crate) fn with_active_label(mut self, present: bool) -> Self { self.has_active_label = present; @@ -563,16 +537,6 @@ impl ExecutionLinearIssueMapping { &self.issue_state } - /// Whether the service queue label is currently present. - pub(crate) fn has_queue_label(&self) -> bool { - self.has_queue_label - } - - /// Whether Decodex may remove the queue label as program-reconciler-owned. - pub(crate) fn queue_label_owned_by_program_reconciler(&self) -> bool { - self.queue_label_owned_by_program_reconciler - } - /// Whether the service active label is currently present. pub(crate) fn has_active_label(&self) -> bool { self.has_active_label @@ -606,12 +570,6 @@ impl ExecutionLinearIssueMapping { )?; validate_required("execution program issue_mapping.issue_state", &self.issue_state)?; - if self.queue_label_owned_by_program_reconciler && !self.has_queue_label { - eyre::bail!( - "Execution program issue_mapping.queue_label_owned_by_program_reconciler requires has_queue_label.", - ); - } - Ok(()) } } @@ -1225,7 +1183,7 @@ impl ExecutionDependencySnapshot { Ok(snapshot) } - /// Observe a dependency through another internal node queue intent. + /// Observe a dependency through another internal node dispatch intent. pub(crate) fn queue_intent( dependency_id: impl Into, queue_intent: ExecutionQueueIntent, @@ -1298,7 +1256,7 @@ pub(crate) struct ExecutionNodeEvaluation { state: ExecutionReadinessState, lifecycle_state: ExecutionProgramNodeLifecycleState, reasons: Vec, - queue_label_action: Option, + dispatch_action: Option, linear_issue: Option, } impl ExecutionNodeEvaluation { @@ -1322,17 +1280,14 @@ impl ExecutionNodeEvaluation { &self.reasons } - /// Queue-label action, if any. - pub(crate) fn queue_label_action(&self) -> Option { - self.queue_label_action + /// Direct dispatch action, if any. + pub(crate) fn dispatch_action(&self) -> Option { + self.dispatch_action } - /// Whether this node may receive or retain the service queue label. - pub(crate) fn queue_label_eligible(&self) -> bool { - matches!( - self.queue_label_action, - Some(ExecutionQueueLabelAction::Apply | ExecutionQueueLabelAction::Retain) - ) + /// Whether this node can be dispatched directly by the program scheduler. + pub(crate) fn dispatchable(&self) -> bool { + matches!(self.dispatch_action, Some(ExecutionDispatchAction::Dispatch)) } /// Mapped Linear issue, when present. @@ -1362,11 +1317,11 @@ impl ExecutionProgramEvaluation { .collect() } - /// Nodes that may receive or retain the service queue label. - pub(crate) fn startable_node_ids(&self) -> Vec<&str> { + /// Nodes that can be dispatched directly by the program scheduler. + pub(crate) fn dispatchable_node_ids(&self) -> Vec<&str> { self.nodes .iter() - .filter(|node| node.queue_label_eligible()) + .filter(|node| node.dispatchable()) .map(|node| node.node_id.as_str()) .collect() } @@ -1386,7 +1341,7 @@ impl ExecutionProgramEvaluation { completed_count: 0, stale_count: 0, superseded_count: 0, - queue_label_eligible_count: 0, + dispatchable_count: 0, mapped_issue_identifiers: Vec::new(), }; @@ -1415,8 +1370,8 @@ impl ExecutionProgramEvaluation { ExecutionProgramNodeLifecycleState::Superseded => summary.superseded_count += 1, } - if node.queue_label_eligible() { - summary.queue_label_eligible_count += 1; + if node.dispatchable() { + summary.dispatchable_count += 1; } if let Some(issue) = &node.linear_issue { @@ -1458,8 +1413,8 @@ pub(crate) struct ExecutionProgramOperatorSummary { pub(crate) stale_count: usize, /// Count of superseded nodes. pub(crate) superseded_count: usize, - /// Count of nodes eligible to receive or retain the service queue label. - pub(crate) queue_label_eligible_count: usize, + /// Count of nodes the program scheduler can dispatch directly. + pub(crate) dispatchable_count: usize, /// Normal Linear issue identifiers linked to the program. pub(crate) mapped_issue_identifiers: Vec, } @@ -1527,12 +1482,12 @@ fn evaluate_node(input: EvaluateNodeInput<'_>) -> Result { state = ExecutionReadinessState::NotReady; - reasons.push(String::from("node queue intent is not-ready")); + reasons.push(String::from("node dispatch intent is not-ready")); }, ExecutionQueueIntent::Paused => { state = ExecutionReadinessState::Paused; - reasons.push(String::from("node queue intent is paused")); + reasons.push(String::from("node dispatch intent is paused")); }, ExecutionQueueIntent::Active => { state = ExecutionReadinessState::Active; @@ -1542,7 +1497,7 @@ fn evaluate_node(input: EvaluateNodeInput<'_>) -> Result { state = ExecutionReadinessState::Completed; - reasons.push(String::from("node queue intent is terminal")); + reasons.push(String::from("node dispatch intent is terminal")); }, ExecutionQueueIntent::ReadyToQueue | ExecutionQueueIntent::Queued => { collect_blocking_readiness_reasons( @@ -1564,16 +1519,15 @@ fn evaluate_node(input: EvaluateNodeInput<'_>) -> Result( } } -fn queue_label_action_for( +fn dispatch_action_for( node: &ExecutionProgramNode, state: ExecutionReadinessState, policy: &ExecutionWorkflowPolicy, -) -> Option { +) -> Option { let issue = node.linear_issue()?; if state != ExecutionReadinessState::Ready { - return removable_program_owned_queue_label(issue); + return None; } if !matches!( node.queue_intent(), ExecutionQueueIntent::ReadyToQueue | ExecutionQueueIntent::Queued ) || !policy.issue_is_startable(issue) { - return removable_program_owned_queue_label(issue); - } - if issue.has_queue_label() { - return issue - .queue_label_owned_by_program_reconciler() - .then_some(ExecutionQueueLabelAction::Retain); + return None; } - Some(ExecutionQueueLabelAction::Apply) -} - -fn removable_program_owned_queue_label( - issue: &ExecutionLinearIssueMapping, -) -> Option { - (issue.has_queue_label() && issue.queue_label_owned_by_program_reconciler()) - .then_some(ExecutionQueueLabelAction::Remove) + Some(ExecutionDispatchAction::Dispatch) } fn lifecycle_state_for( node: &ExecutionProgramNode, state: ExecutionReadinessState, - queue_label_action: Option, ) -> ExecutionProgramNodeLifecycleState { if let Some(issue) = node.linear_issue() && issue.has_needs_attention_label @@ -1760,13 +1701,7 @@ fn lifecycle_state_for( } else { ExecutionProgramNodeLifecycleState::Planned }, - ExecutionReadinessState::Ready => { - if matches!(queue_label_action, Some(ExecutionQueueLabelAction::Retain)) { - ExecutionProgramNodeLifecycleState::Queued - } else { - ExecutionProgramNodeLifecycleState::Ready - } - }, + ExecutionReadinessState::Ready => ExecutionProgramNodeLifecycleState::Ready, ExecutionReadinessState::Blocked => ExecutionProgramNodeLifecycleState::Blocked, ExecutionReadinessState::Active => ExecutionProgramNodeLifecycleState::Active, ExecutionReadinessState::Completed => ExecutionProgramNodeLifecycleState::Completed, @@ -1850,10 +1785,10 @@ mod tests { use crate::{ execution_program::{ ExecutionConflictDomain, ExecutionConflictDomainKind, ExecutionDependencySnapshot, - ExecutionLinearIssueMapping, ExecutionProgram, ExecutionProgramDependency, - ExecutionProgramNode, ExecutionProgramNodeLifecycleState, ExecutionProgramNodeStage, - ExecutionProgramReadinessContext, ExecutionQueueIntent, ExecutionQueueLabelAction, - ExecutionReadinessState, ExecutionWorkflowPolicy, ProgramIntakeKind, + ExecutionDispatchAction, ExecutionLinearIssueMapping, ExecutionProgram, + ExecutionProgramDependency, ExecutionProgramNode, ExecutionProgramNodeStage, + ExecutionProgramReadinessContext, ExecutionQueueIntent, ExecutionReadinessState, + ExecutionWorkflowPolicy, ProgramIntakeKind, }, loop_contract::{DecisionContract, DecisionPromotion, DecisionPromotionActorKind}, }; @@ -1945,10 +1880,10 @@ mod tests { .expect("program should evaluate"); assert_eq!(evaluation.ready_node_ids(), vec!["node-ready"]); - assert_eq!(evaluation.startable_node_ids(), vec!["node-ready"]); + assert_eq!(evaluation.dispatchable_node_ids(), vec!["node-ready"]); assert_eq!( - evaluation.nodes()[0].queue_label_action(), - Some(ExecutionQueueLabelAction::Apply) + evaluation.nodes()[0].dispatch_action(), + Some(ExecutionDispatchAction::Dispatch) ); assert_eq!(evaluation.operator_summary().ready_count, 1); assert_eq!(evaluation.operator_summary().blocked_count, 1); @@ -2020,11 +1955,11 @@ mod tests { .evaluate(&contract, &workflow_policy(), &ready_context) .expect("program should evaluate"); - assert!(ready.startable_node_ids().contains(&"node-dependent")); + assert!(ready.dispatchable_node_ids().contains(&"node-dependent")); } #[test] - fn stale_contract_drift_blocks_queue_retention() { + fn stale_contract_drift_blocks_direct_dispatch() { let stale_node = ready_node("node-stale", "XY-903") .with_contract_fingerprint("stale-contract-fingerprint") .expect("fingerprint should override"); @@ -2035,8 +1970,8 @@ mod tests { let node = &evaluation.nodes()[0]; assert_eq!(node.state(), ExecutionReadinessState::Stale); - assert_eq!(node.queue_label_action(), None); - assert!(evaluation.startable_node_ids().is_empty()); + assert_eq!(node.dispatch_action(), None); + assert!(evaluation.dispatchable_node_ids().is_empty()); } #[test] @@ -2061,69 +1996,6 @@ mod tests { assert!(node.reasons().iter().any(|reason| reason.contains("already occupied"))); } - #[test] - fn linear_issue_mapping_controls_apply_retain_and_remove() { - let apply = ready_node("node-apply", "XY-905"); - let retain = ready_node("node-retain", "XY-906") - .with_linear_issue(issue("XY-906", "Todo").with_program_owned_queue_label(true)) - .expect("issue should attach"); - let ready_human_owned = ready_node("node-ready-human-owned", "XY-910") - .with_linear_issue(issue("XY-910", "Todo").with_queue_label(true)) - .expect("issue should attach"); - let remove = ready_node("node-remove", "XY-907") - .with_linear_issue(issue("XY-907", "In Progress").with_program_owned_queue_label(true)) - .expect("issue should attach"); - let human_owned = ready_node("node-human-owned", "XY-909") - .with_linear_issue(issue("XY-909", "In Progress").with_queue_label(true)) - .expect("issue should attach"); - let (contract, program) = - program_with(vec![apply, retain, ready_human_owned, remove, human_owned]); - let evaluation = program - .evaluate(&contract, &workflow_policy(), &ExecutionProgramReadinessContext::new()) - .expect("program should evaluate"); - let action_for = |id: &str| { - evaluation - .nodes() - .iter() - .find(|node| node.node_id() == id) - .expect("node should exist") - .queue_label_action() - }; - - assert_eq!(action_for("node-apply"), Some(ExecutionQueueLabelAction::Apply)); - assert_eq!(action_for("node-retain"), Some(ExecutionQueueLabelAction::Retain)); - assert_eq!(action_for("node-ready-human-owned"), None); - assert_eq!(action_for("node-remove"), Some(ExecutionQueueLabelAction::Remove)); - assert_eq!(action_for("node-human-owned"), None); - assert_eq!( - evaluation - .nodes() - .iter() - .find(|node| node.node_id() == "node-remove") - .expect("node should exist") - .state(), - ExecutionReadinessState::Blocked - ); - assert_eq!( - evaluation - .nodes() - .iter() - .find(|node| node.node_id() == "node-retain") - .expect("node should exist") - .lifecycle_state(), - ExecutionProgramNodeLifecycleState::Queued - ); - assert_eq!( - evaluation - .nodes() - .iter() - .find(|node| node.node_id() == "node-ready-human-owned") - .expect("node should exist") - .lifecycle_state(), - ExecutionProgramNodeLifecycleState::Ready - ); - } - #[test] fn unmapped_ready_to_queue_node_is_blocked_from_startable_selection() { let unmapped = ExecutionProgramNode::new( @@ -2145,7 +2017,7 @@ mod tests { assert_eq!(node.state(), ExecutionReadinessState::Blocked); assert!(node.reasons().iter().any(|reason| reason.contains("no normal Linear issue"))); - assert!(evaluation.startable_node_ids().is_empty()); + assert!(evaluation.dispatchable_node_ids().is_empty()); } #[test] diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index b51d079c1..a638f796c 100644 --- a/apps/decodex/src/orchestrator.rs +++ b/apps/decodex/src/orchestrator.rs @@ -40,7 +40,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{agent, default_branch_sync, git_credentials, maintenance, state}; #[rustfmt::skip] -use crate::{agent::{ACTIVE_RUN_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerDynamicToolFailure, AppServerHomePreflightFailure, AppServerPhaseGoalFailure, AppServerProcessEnv, AppServerRunRequest, AppServerRunResult, AppServerTransportFailure, AppServerTurnFailure, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, DecodexRunContext, DecodexToolBridge, PhaseGoalController, PhaseGoalKind, PhaseGoalSpec, PhaseGoalTransition, ReviewExecutionMode, ReviewHandoffContext, ReviewHandoffWritebackFailed, ReviewPolicyStopReason, ReviewPolicyStopRequested, RunCompletionDisposition, TrackerToolBridge, TurnContinuationGuard}, config::{ReviewLevel, ServiceConfig}, execution_program::{ExecutionNodeEvaluation, ExecutionProgramEvaluation, ExecutionProgramOperatorSummary, ExecutionProgramReadinessContext, ExecutionQueueLabelAction, ExecutionWorkflowPolicy}, git_credentials::GitCredentialSource, github, prelude::{Result, eyre}, state::{ChildAgentActivityBucket, ChildAgentActivitySummary, CodexAccountActivitySummary, ExecutionProgramRecord, LoopGuardrailCheckpoint, LoopGuardrailCheckpointInput, ProjectRegistration, ProjectRunStatus, ProtocolActivitySummary, RUN_OPERATION_AGENT_RUN, RUN_OPERATION_APP_SERVER_PREFLIGHT, RUN_OPERATION_GIT_CREDENTIALS, RUN_OPERATION_IDLE, RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, RUN_OPERATION_REVIEW_WRITEBACK, RUN_OPERATION_WAITING_EXTERNAL, ReviewHandoffMarker, ReviewOrchestrationMarker, RunActivityMarker, RunAttempt, StateStore, WorktreeMapping}, tracker::{IssueTracker, TrackerComment, TrackerIssue, linear::LinearClient, records}, workflow::{WorkflowDocument, WorkflowExecution}, worktree::{WorktreeManager, WorktreeSpec}}; +use crate::{agent::{ACTIVE_RUN_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerDynamicToolFailure, AppServerHomePreflightFailure, AppServerPhaseGoalFailure, AppServerProcessEnv, AppServerRunRequest, AppServerRunResult, AppServerTransportFailure, AppServerTurnFailure, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, DecodexRunContext, DecodexToolBridge, PhaseGoalController, PhaseGoalKind, PhaseGoalSpec, PhaseGoalTransition, ReviewExecutionMode, ReviewHandoffContext, ReviewHandoffWritebackFailed, ReviewPolicyStopReason, ReviewPolicyStopRequested, RunCompletionDisposition, TrackerToolBridge, TurnContinuationGuard}, config::{ReviewLevel, ServiceConfig}, execution_program::{ExecutionNodeEvaluation, ExecutionProgramEvaluation, ExecutionProgramOperatorSummary, ExecutionProgramReadinessContext, ExecutionWorkflowPolicy}, git_credentials::GitCredentialSource, github, prelude::{Result, eyre}, state::{ChildAgentActivityBucket, ChildAgentActivitySummary, CodexAccountActivitySummary, ExecutionProgramRecord, LoopGuardrailCheckpoint, LoopGuardrailCheckpointInput, ProjectRegistration, ProjectRunStatus, ProtocolActivitySummary, RUN_OPERATION_AGENT_RUN, RUN_OPERATION_APP_SERVER_PREFLIGHT, RUN_OPERATION_GIT_CREDENTIALS, RUN_OPERATION_IDLE, RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, RUN_OPERATION_REVIEW_WRITEBACK, RUN_OPERATION_WAITING_EXTERNAL, ReviewHandoffMarker, ReviewOrchestrationMarker, RunActivityMarker, RunAttempt, StateStore, WorktreeMapping}, tracker::{IssueTracker, TrackerComment, TrackerIssue, linear::LinearClient, records}, workflow::{WorkflowDocument, WorkflowExecution}, worktree::{WorktreeManager, WorktreeSpec}}; use harness_improvement::{ HarnessImprovementCandidateSummary, HarnessOutcomeKind, harness_improvement_candidates_from_private_events, record_harness_outcome_best_effort, diff --git a/apps/decodex/src/orchestrator/daemon.rs b/apps/decodex/src/orchestrator/daemon.rs index acbc1d1c0..d00653399 100644 --- a/apps/decodex/src/orchestrator/daemon.rs +++ b/apps/decodex/src/orchestrator/daemon.rs @@ -149,25 +149,6 @@ where state_store, context.review_state_inspector, )?; - - let program_reconciliation = reconcile_execution_program_queue_labels( - context.tracker, - context.project, - context.workflow, - state_store, - )?; - - if program_reconciliation.label_mutation_count() > 0 { - tracing::info!( - project_id = context.project.service_id(), - programs_evaluated = program_reconciliation.programs_evaluated, - programs_updated = program_reconciliation.programs_updated, - labels_applied = program_reconciliation.labels_applied, - labels_removed = program_reconciliation.labels_removed, - "Reconciled Execution Program queue labels before normal issue selection." - ); - } - reconcile_terminal_thread_archive_backlog_best_effort( context.project, context.workflow, diff --git a/apps/decodex/src/orchestrator/dispatch_policy.rs b/apps/decodex/src/orchestrator/dispatch_policy.rs index 8bc4f2628..ddcd1f53d 100644 --- a/apps/decodex/src/orchestrator/dispatch_policy.rs +++ b/apps/decodex/src/orchestrator/dispatch_policy.rs @@ -33,14 +33,14 @@ where if issue.has_label(tracker_policy.needs_attention_label()) { return Ok(false); } - if issue.labels_complete { - if !issue.has_label(queue_label) { + if !queue_membership_confirmed_by_source { + if issue.labels_complete { + if !issue.has_label(queue_label) { + return Ok(false); + } + } else if !tracker::issue_has_label_with_server_confirmation(tracker, issue, queue_label)? { return Ok(false); } - } else if !queue_membership_confirmed_by_source - && !tracker::issue_has_label_with_server_confirmation(tracker, issue, queue_label)? - { - return Ok(false); } if !todo_blocker_rule_passes(issue, workflow) { return Ok(false); @@ -459,7 +459,7 @@ fn retry_budget_base_for_dispatch_mode( ) -> Result { let preferred_retry_budget_base = preferred_retry_budget_base.unwrap_or(0); - if dispatch_mode == IssueDispatchMode::Normal { + if matches!(dispatch_mode, IssueDispatchMode::Normal | IssueDispatchMode::Program) { return Ok(preferred_retry_budget_base); } @@ -606,9 +606,10 @@ where IssueDispatchMode::ReviewRepair => { Ok(!issue_passes_review_repair_dispatch_policy(tracker, issue, project, workflow)?) }, - IssueDispatchMode::Normal | IssueDispatchMode::Retry | IssueDispatchMode::Closeout => { - Ok(is_issue_nonactive_for_run(issue, workflow)) - }, + IssueDispatchMode::Normal + | IssueDispatchMode::Program + | IssueDispatchMode::Retry + | IssueDispatchMode::Closeout => Ok(is_issue_nonactive_for_run(issue, workflow)), } } diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index 353890b0e..0263150b3 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -164,7 +164,7 @@ struct RepoGatePhaseGoalController<'a> { impl RepoGatePhaseGoalController<'_> { fn initial_phase_goal_kind(&self) -> PhaseGoalKind { match self.issue_run.dispatch_mode { - IssueDispatchMode::Normal | IssueDispatchMode::Retry => + IssueDispatchMode::Normal | IssueDispatchMode::Program | IssueDispatchMode::Retry => PhaseGoalKind::ImplementToValidationReady, IssueDispatchMode::ReviewRepair => PhaseGoalKind::RepairAcceptedReviewFindings, IssueDispatchMode::Closeout => PhaseGoalKind::HandoffEvidence, @@ -2168,7 +2168,7 @@ fn planned_issue_state_for_dispatch( preferred_issue_state: Option<&str>, ) -> String { match dispatch_mode { - IssueDispatchMode::Normal => + IssueDispatchMode::Normal | IssueDispatchMode::Program => workflow.frontmatter().tracker().in_progress_state().to_owned(), IssueDispatchMode::Retry => preferred_issue_state .filter(|state| { diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index a9ad9060c..04a8ef977 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -9647,8 +9647,8 @@

Run History

(total, program) => total + Number(program.ready_count || 0), 0, ); - const programQueuedCount = executionPrograms.reduce( - (total, program) => total + Number(program.queued_count || 0), + const programDispatchableCount = executionPrograms.reduce( + (total, program) => total + Number(program.dispatchable_count || 0), 0, ); @@ -9681,7 +9681,7 @@

Run History

executionPrograms, programAttentionCount, programReadyCount, - programQueuedCount, + programDispatchableCount, sessionHistoryRuns: historyRuns, worktrees, postReviewLanes, @@ -10310,8 +10310,8 @@

Run History

if (derived.programReadyCount) { parts.push(`${derived.programReadyCount} ready`); } - if (derived.programQueuedCount) { - parts.push(`${derived.programQueuedCount} queued`); + if (derived.programDispatchableCount) { + parts.push(`${derived.programDispatchableCount} dispatchable`); } if (derived.programAttentionCount) { parts.push( @@ -10350,29 +10350,17 @@

Run History

return issues.length ? issues.join(", ") : "NONE"; } - function programQueueActionSummary(program) { - const actions = [ - ["apply", program.queue_label_apply_count], - ["retain", program.queue_label_retain_count], - ["remove", program.queue_label_remove_count], - ].filter(([, count]) => Number(count || 0) > 0); - - return actions.length - ? actions.map(([label, count]) => `${label} ${count}`).join(", ") - : "none"; - } - function programProgressFacts(program) { return [ ["Ready", String(program.ready_count ?? 0)], ["Queued", String(program.queued_count ?? 0)], + ["Dispatchable", String(program.dispatchable_count ?? 0)], ["Active", String(program.active_count ?? 0)], ["Blocked", String(program.blocked_count ?? 0)], ["Held", String(program.held_count ?? 0)], ["Attention", String(program.needs_attention_count ?? 0)], ["Completed", String(program.completed_count ?? 0)], ["Stale", String(program.stale_count ?? 0)], - ["Eligible", String(program.queue_label_eligible_count ?? 0)], ]; } @@ -10410,7 +10398,7 @@

Run History

${field("Lifecycle", displayToken(node.lifecycle_state || "unknown"))} ${field("Readiness", displayToken(node.readiness_state || "unknown"))} ${field("Issue state", node.issue_state || "none")} - ${field("Queue action", node.queue_label_action || "none")} + ${field("Dispatch action", node.dispatch_action || "none")} ${field("Reason codes", reasonCodes)} ${field("Reasons", programNodeReasons(node))} ${field("Next action", node.next_action || "none")} @@ -10457,7 +10445,7 @@

${escapeHtml(program.public_summary || program.program_id)}

${statusLabel(displayToken(program.status || "unknown"), tone)} - ${inlineStatusFact("Queue", programQueueActionSummary(program))} + ${inlineStatusFact("Dispatchable", String(program.dispatchable_count ?? 0))} ${warning}
diff --git a/apps/decodex/src/orchestrator/program_reconciler.rs b/apps/decodex/src/orchestrator/program_reconciler.rs index ca9c671ce..7e2b12987 100644 --- a/apps/decodex/src/orchestrator/program_reconciler.rs +++ b/apps/decodex/src/orchestrator/program_reconciler.rs @@ -1,5 +1,6 @@ use crate::execution_program::{ ExecutionDependencySnapshot, ExecutionLinearIssueMapping, ExecutionProgram, + ExecutionReadinessState, }; use crate::execution_program::ExecutionConflictDomain; @@ -13,8 +14,6 @@ struct RefreshedExecutionProgram { #[derive(Clone)] struct ProgramIssueSnapshot { issue: TrackerIssue, - has_queue_label: bool, - queue_label_owned_by_current_program: bool, has_active_label: bool, has_opt_out_label: bool, has_needs_attention_label: bool, @@ -22,25 +21,13 @@ struct ProgramIssueSnapshot { has_generic_dispatch_briefing: bool, } impl ProgramIssueSnapshot { - fn linear_mapping( - &self, - has_queue_label: bool, - queue_label_program_owned: bool, - ) -> Result { - let mut mapping = ExecutionLinearIssueMapping::new( + fn linear_mapping(&self) -> Result { + Ok(ExecutionLinearIssueMapping::new( &self.issue.id, &self.issue.identifier, &self.issue.state.name, - )?; - - mapping = if queue_label_program_owned { - mapping.with_program_owned_queue_label(true) - } else { - mapping.with_queue_label(has_queue_label) - }; - - Ok(mapping - .with_active_label(self.has_active_label) + )? + .with_active_label(self.has_active_label) .with_opt_out_label(self.has_opt_out_label) .with_needs_attention_label(self.has_needs_attention_label) .with_open_tracker_blockers(self.has_open_tracker_blockers) @@ -55,45 +42,70 @@ where tracker: &'a T, service_id: &'a str, workflow: &'a WorkflowDocument, - state_store: &'a StateStore, - queue_label: &'a str, - record: &'a ExecutionProgramRecord, - node_id: &'a str, issue: &'a TrackerIssue, } #[derive(Default)] -struct ProgramReconciliationSummary { +struct ProgramSchedulerSummary { programs_evaluated: usize, programs_updated: usize, - labels_applied: usize, - labels_removed: usize, - labels_retained: usize, + dispatchable_nodes: usize, } -impl ProgramReconciliationSummary { - fn label_mutation_count(&self) -> usize { - self.labels_applied + self.labels_removed - } + +struct ProgramSchedulerSelection { + selected: Option, + summary: ProgramSchedulerSummary, } -struct ProgramQueueLabelState { - has_queue_label: bool, - program_owned: bool, +fn select_execution_program_run_candidate( + tracker: &T, + project: &ServiceConfig, + workflow: &WorkflowDocument, + state_store: &StateStore, + excluded_issue_ids: &[&str], +) -> Result> +where + T: IssueTracker + ?Sized, +{ + let ProgramSchedulerSelection { selected, summary } = + select_execution_program_run_candidate_with_summary( + tracker, + project, + workflow, + state_store, + excluded_issue_ids, + )?; + + if summary.dispatchable_nodes > 0 || summary.programs_updated > 0 { + tracing::info!( + project_id = project.service_id(), + programs_evaluated = summary.programs_evaluated, + programs_updated = summary.programs_updated, + dispatchable_nodes = summary.dispatchable_nodes, + "Evaluated Execution Programs for direct graph dispatch." + ); + } + + Ok(selected) } -fn reconcile_execution_program_queue_labels( +fn select_execution_program_run_candidate_with_summary( tracker: &T, project: &ServiceConfig, workflow: &WorkflowDocument, state_store: &StateStore, -) -> Result + excluded_issue_ids: &[&str], +) -> Result where T: IssueTracker + ?Sized, { let records = state_store.list_execution_programs(project.service_id())?; if records.is_empty() { - return Ok(ProgramReconciliationSummary::default()); + return Ok(ProgramSchedulerSelection { + selected: None, + summary: ProgramSchedulerSummary::default(), + }); } let policy = ExecutionWorkflowPolicy::from_workflow(project.service_id(), workflow)?; @@ -105,8 +117,6 @@ where tracker, project.service_id(), workflow, - state_store, - &policy, record, &refreshed_issues, ) @@ -118,7 +128,8 @@ where state_store, &refreshed_programs, )?; - let mut summary = ProgramReconciliationSummary::default(); + let mut summary = ProgramSchedulerSummary::default(); + let mut candidates = Vec::new(); for refreshed in refreshed_programs { let evaluation = if let Some(source_contract_id) = refreshed.record.source_contract_id() { @@ -135,24 +146,44 @@ where summary.programs_evaluated += 1; - let final_program = apply_execution_program_queue_actions( - tracker, - project.service_id(), - policy.queue_label(), - refreshed.program, - &refreshed.issues_by_node, - evaluation.nodes(), - &mut summary, - )?; + for node in evaluation.nodes() { + if node.state() != ExecutionReadinessState::Ready { + continue; + } + + let Some(mapping) = node.linear_issue() else { + continue; + }; + + if excluded_issue_ids.contains(&mapping.issue_id()) { + continue; + } + + let Some(snapshot) = refreshed.issues_by_node.get(node.node_id()) else { + continue; + }; + + summary.dispatchable_nodes += 1; - if final_program != *refreshed.record.program() { - state_store.upsert_execution_program(project.service_id(), final_program)?; + candidates.push(snapshot.issue.clone()); + } + + if refreshed.program != *refreshed.record.program() { + state_store.upsert_execution_program(project.service_id(), refreshed.program)?; summary.programs_updated += 1; } } - Ok(summary) + candidates.sort_by(compare_issue_candidates); + + Ok(ProgramSchedulerSelection { + selected: candidates + .into_iter() + .next() + .map(|issue| SelectedIssueRunCandidate::new(issue, IssueDispatchMode::Program)), + summary, + }) } fn refresh_execution_program_issues( @@ -185,8 +216,6 @@ fn refresh_execution_program_tracker_facts( tracker: &T, service_id: &str, workflow: &WorkflowDocument, - state_store: &StateStore, - policy: &ExecutionWorkflowPolicy, record: ExecutionProgramRecord, refreshed_issues: &std::collections::BTreeMap, ) -> Result @@ -211,14 +240,9 @@ where tracker, service_id, workflow, - state_store, - queue_label: policy.queue_label(), - record: &record, - node_id: node.node_id(), issue, })?; - let mapping = - snapshot.linear_mapping(snapshot.has_queue_label, snapshot.queue_label_owned_by_current_program)?; + let mapping = snapshot.linear_mapping()?; refreshed_nodes.push(node.clone().with_linear_issue(mapping)?); issues_by_node.insert(node.node_id().to_owned(), snapshot); @@ -237,29 +261,9 @@ where tracker, service_id, workflow, - state_store, - queue_label, - record, - node_id, issue, } = input; let tracker_policy = workflow.frontmatter().tracker(); - let has_queue_label = - tracker::issue_has_label_with_server_confirmation(tracker, issue, queue_label)?; - let ownership = state_store.program_queue_label_ownership_for_issue( - service_id, - &issue.id, - queue_label, - )?; - let queue_label_owned_by_current_program = has_queue_label - && ownership.iter().any(|recorded| { - recorded.service_id() == service_id - && recorded.program_id() == record.program_id() - && recorded.node_id() == node_id - && recorded.issue_id() == issue.id - && recorded.issue_identifier() == issue.identifier - && recorded.label_name() == queue_label - }); let has_active_label = tracker::issue_has_label_with_server_confirmation( tracker, issue, @@ -277,8 +281,6 @@ where Ok(ProgramIssueSnapshot { issue: issue.clone(), - has_queue_label, - queue_label_owned_by_current_program, has_active_label, has_opt_out_label, has_needs_attention_label, @@ -405,97 +407,3 @@ fn program_issue_occupies_conflict_domain( || retained_nonterminal || state_store.issue_has_active_shared_claim(service_id, &issue.id)?) } - -fn apply_execution_program_queue_actions( - tracker: &T, - service_id: &str, - queue_label: &str, - program: ExecutionProgram, - issues_by_node: &std::collections::BTreeMap, - evaluations: &[ExecutionNodeEvaluation], - summary: &mut ProgramReconciliationSummary, -) -> Result -where - T: IssueTracker + ?Sized, -{ - let evaluations_by_node = evaluations - .iter() - .map(|evaluation| (evaluation.node_id().to_owned(), evaluation)) - .collect::>(); - let mut final_nodes = Vec::with_capacity(program.nodes().len()); - - for node in program.nodes() { - let (Some(snapshot), Some(evaluation)) = ( - issues_by_node.get(node.node_id()), - evaluations_by_node.get(node.node_id()), - ) else { - final_nodes.push(node.clone()); - - continue; - }; - let label_state = apply_execution_program_node_queue_action( - tracker, - service_id, - queue_label, - snapshot, - evaluation.queue_label_action(), - summary, - )?; - let mapping = - snapshot.linear_mapping(label_state.has_queue_label, label_state.program_owned)?; - - final_nodes.push(node.clone().with_linear_issue(mapping)?); - } - - program.with_nodes(final_nodes) -} - -fn apply_execution_program_node_queue_action( - tracker: &T, - service_id: &str, - queue_label: &str, - snapshot: &ProgramIssueSnapshot, - action: Option, - summary: &mut ProgramReconciliationSummary, -) -> Result -where - T: IssueTracker + ?Sized, -{ - match action { - Some(ExecutionQueueLabelAction::Apply) => { - if tracker::set_issue_label_presence(tracker, &snapshot.issue, queue_label, true)? { - summary.labels_applied += 1; - } - - Ok(ProgramQueueLabelState { has_queue_label: true, program_owned: true }) - }, - Some(ExecutionQueueLabelAction::Retain) => { - summary.labels_retained += 1; - - Ok(ProgramQueueLabelState { has_queue_label: true, program_owned: true }) - }, - Some(ExecutionQueueLabelAction::Remove) => { - if snapshot.queue_label_owned_by_current_program - && tracker::set_issue_label_presence(tracker, &snapshot.issue, queue_label, false)? - { - summary.labels_removed += 1; - } - - Ok(ProgramQueueLabelState { has_queue_label: false, program_owned: false }) - }, - None => { - if snapshot.has_queue_label && snapshot.queue_label_owned_by_current_program { - tracing::debug!( - project_id = service_id, - issue = snapshot.issue.identifier, - "Retained program-owned queue label without a readiness action." - ); - } - - Ok(ProgramQueueLabelState { - has_queue_label: snapshot.has_queue_label, - program_owned: snapshot.queue_label_owned_by_current_program, - }) - }, - } -} diff --git a/apps/decodex/src/orchestrator/run_cycle.rs b/apps/decodex/src/orchestrator/run_cycle.rs index 4a7101d85..60a2bb1d8 100644 --- a/apps/decodex/src/orchestrator/run_cycle.rs +++ b/apps/decodex/src/orchestrator/run_cycle.rs @@ -1249,14 +1249,13 @@ where reconcile_post_review_orchestration(tracker, project, workflow, state_store)?; } - let issues = queued_issues_for_dispatch(tracker, project, workflow, dry_run)?; let Some(selected_issue) = select_project_issue_run_candidate( tracker, project, workflow, state_store, recovered_state, - issues, + dry_run, excluded_issue_ids, )? else { return Ok(None); @@ -1340,7 +1339,7 @@ fn select_project_issue_run_candidate( workflow: &WorkflowDocument, state_store: &StateStore, recovered_state: RecoveredRuntimeState, - issues: Vec, + dry_run: bool, excluded_issue_ids: &[&str], ) -> Result> where @@ -1359,6 +1358,13 @@ where if let Some(candidate) = selected_retry_issue.or(selected_post_review_issue) { return Ok(Some(candidate)); } + if let Some(candidate) = + select_execution_program_run_candidate(tracker, project, workflow, state_store, excluded_issue_ids)? + { + return Ok(Some(candidate)); + } + + let issues = queued_issues_for_dispatch(tracker, project, workflow, dry_run)?; Ok(select_issue_candidate_with_exclusions( tracker, @@ -2572,7 +2578,7 @@ where || summary.continuation_pending || !matches!( summary.dispatch_mode, - IssueDispatchMode::Normal | IssueDispatchMode::ReviewRepair + IssueDispatchMode::Normal | IssueDispatchMode::Program | IssueDispatchMode::ReviewRepair ) { return Ok(None); diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 38803f12c..8f0c767a1 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -6877,7 +6877,7 @@ fn append_rendered_execution_programs(output: &mut String, snapshot: &OperatorSt let public_summary = program.public_summary.as_deref().unwrap_or("none"); output.push_str(&format!( - "- program_id: {} status={} source_contract_id: {} intake_kind={} summary=\"{}\" nodes={} planned={} mapped={} ready={} queued={} blocked={} held={} active={} attention={} completed={} stale={} superseded={} queue_label_eligible={} queue_actions=apply:{} retain:{} remove:{} mapped_issues={}{}\n", + "- program_id: {} status={} source_contract_id: {} intake_kind={} summary=\"{}\" nodes={} planned={} mapped={} ready={} queued={} blocked={} held={} active={} attention={} completed={} stale={} superseded={} dispatchable={} mapped_issues={}{}\n", program.program_id, program.status, program.source_contract_id.as_deref().unwrap_or("none"), @@ -6895,10 +6895,7 @@ fn append_rendered_execution_programs(output: &mut String, snapshot: &OperatorSt program.completed_count, program.stale_count, program.superseded_count, - program.queue_label_eligible_count, - program.queue_label_apply_count, - program.queue_label_retain_count, - program.queue_label_remove_count, + program.dispatchable_count, mapped_issues, readback_warning, )); @@ -6906,7 +6903,7 @@ fn append_rendered_execution_programs(output: &mut String, snapshot: &OperatorSt for node in &program.node_readbacks { let issue_identifier = node.issue_identifier.as_deref().unwrap_or("unmapped"); let issue_state = node.issue_state.as_deref().unwrap_or("none"); - let queue_label_action = node.queue_label_action.as_deref().unwrap_or("none"); + let dispatch_action = node.dispatch_action.as_deref().unwrap_or("none"); let reason_codes = if node.reason_codes.is_empty() { String::from("none") } else { @@ -6919,12 +6916,12 @@ fn append_rendered_execution_programs(output: &mut String, snapshot: &OperatorSt }; output.push_str(&format!( - " - node: issue={} issue_state={} lifecycle={} readiness={} queue_label_action={} reason_codes={} reasons=\"{}\" next_action=\"{}\"\n", + " - node: issue={} issue_state={} lifecycle={} readiness={} dispatch_action={} reason_codes={} reasons=\"{}\" next_action=\"{}\"\n", issue_identifier, issue_state, node.lifecycle_state, node.readiness_state, - queue_label_action, + dispatch_action, reason_codes, reasons, node.next_action, diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 2a4145723..ee242efb6 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -56,10 +56,9 @@ fn operator_dashboard_surfaces_program_intake_panel() { assert!(response.contains("programsMeta: document.getElementById(\"programs-meta\")")); assert!(response.contains("function renderExecutionPrograms(snapshot, derived)")); assert!(response.contains("function renderProgramNodeReadbacks(program)")); - assert!(response.contains("function programQueueActionSummary(program)")); assert!(response.contains("program.node_readbacks ?? []")); - assert!(response.contains("program.queue_label_apply_count")); - assert!(response.contains("program.queue_label_remove_count")); + assert!(response.contains("program.dispatchable_count")); + assert!(response.contains("node.dispatch_action")); assert!(response.contains("renderExecutionPrograms(snapshot, derived);")); assert!(response.contains("primary: [\"accountPool\", \"projects\", \"active\", \"programs\", \"queue\", \"review\", \"worktrees\", \"recent\"]")); assert!(response.contains("{ marker: \"execution\", panels: [\"active\", \"programs\", \"queue\"] }")); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs index 6a3a23db7..235e186b5 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs @@ -201,17 +201,14 @@ fn operator_status_text_surfaces_execution_program_summary() { completed_count: 1, stale_count: 0, superseded_count: 0, - queue_label_eligible_count: 1, - queue_label_apply_count: 1, - queue_label_retain_count: 0, - queue_label_remove_count: 1, + dispatchable_count: 0, mapped_issue_identifiers: vec![String::from("XY-853")], node_readbacks: vec![OperatorExecutionProgramNodeStatus { lifecycle_state: String::from("blocked"), readiness_state: String::from("blocked"), issue_identifier: Some(String::from("XY-853")), issue_state: Some(String::from("Todo")), - queue_label_action: Some(String::from("remove")), + dispatch_action: None, reason_codes: vec![String::from("dependency_not_terminal")], reasons: vec![String::from( "a dependency has not reached a required terminal state", @@ -230,15 +227,15 @@ fn operator_status_text_surfaces_execution_program_summary() { assert!(rendered.contains("Execution programs: 1")); assert!(rendered.contains("Execution Programs")); assert!(rendered.contains( - "program_id: program-853 status=blocked source_contract_id: contract-852 intake_kind=goal_intake summary=\"Resolve promoted program work.\" nodes=3 planned=0 mapped=0 ready=1 queued=0 blocked=1 held=0 active=0 attention=0 completed=1 stale=0 superseded=0 queue_label_eligible=1 queue_actions=apply:1 retain:0 remove:1 mapped_issues=XY-853" + "program_id: program-853 status=blocked source_contract_id: contract-852 intake_kind=goal_intake summary=\"Resolve promoted program work.\" nodes=3 planned=0 mapped=0 ready=1 queued=0 blocked=1 held=0 active=0 attention=0 completed=1 stale=0 superseded=0 dispatchable=0 mapped_issues=XY-853" )); assert!(rendered.contains( - "node: issue=XY-853 issue_state=Todo lifecycle=blocked readiness=blocked queue_label_action=remove reason_codes=dependency_not_terminal reasons=\"a dependency has not reached a required terminal state\" next_action=\"Complete the dependency issue or refresh the Execution Program dependency plan if this remains stale.\"" + "node: issue=XY-853 issue_state=Todo lifecycle=blocked readiness=blocked dispatch_action=none reason_codes=dependency_not_terminal reasons=\"a dependency has not reached a required terminal state\" next_action=\"Complete the dependency issue or refresh the Execution Program dependency plan if this remains stale.\"" )); } #[test] -fn operator_status_json_accepts_legacy_program_rows_without_new_fields() { +fn operator_status_json_uses_direct_dispatch_program_fields() { let snapshot: OperatorStatusSnapshot = serde_json::from_value(serde_json::json!({ "project_id": "decodex", "run_limit": 10, @@ -269,24 +266,35 @@ fn operator_status_json_accepts_legacy_program_rows_without_new_fields() { "completed_count": 0, "stale_count": 0, "superseded_count": 0, - "queue_label_eligible_count": 1, + "dispatchable_count": 1, "mapped_issue_identifiers": ["XY-853"], + "node_readbacks": [{ + "lifecycle_state": "ready", + "readiness_state": "ready", + "issue_identifier": "XY-853", + "issue_state": "Todo", + "dispatch_action": "dispatch", + "reason_codes": ["ready_for_linear_execution"], + "reasons": ["node is ready for normal Linear issue execution"], + "next_action": "The program scheduler can dispatch this node directly." + }], "readback_warning": null, }], "queued_candidates": [], "worktrees": [], "post_review_lanes": [], })) - .expect("legacy operator snapshot should deserialize"); + .expect("operator snapshot should deserialize"); let program = snapshot.execution_programs.first().expect("program should deserialize"); assert_eq!(program.status, "unknown"); assert_eq!(program.intake_kind, None); assert_eq!(program.public_summary, None); - assert_eq!(program.queue_label_apply_count, 0); - assert_eq!(program.queue_label_retain_count, 0); - assert_eq!(program.queue_label_remove_count, 0); - assert!(program.node_readbacks.is_empty()); + assert_eq!(program.dispatchable_count, 1); + assert_eq!( + program.node_readbacks[0].dispatch_action.as_deref(), + Some("dispatch") + ); } #[test] @@ -318,7 +326,6 @@ fn seed_program_readback_status(state_store: &StateStore, config: &ServiceConfig "PUB-941", "Todo", ExecutionQueueIntent::ReadyToQueue, - false, ), status_program_node( "node-queued", @@ -326,7 +333,6 @@ fn seed_program_readback_status(state_store: &StateStore, config: &ServiceConfig "PUB-942", "Todo", ExecutionQueueIntent::Queued, - true, ), status_program_node_with_dependency( "node-blocked", @@ -334,7 +340,6 @@ fn seed_program_readback_status(state_store: &StateStore, config: &ServiceConfig "PUB-943", "Todo", "PUB-944", - true, ), status_program_node( "node-dependency", @@ -342,7 +347,6 @@ fn seed_program_readback_status(state_store: &StateStore, config: &ServiceConfig "PUB-944", "Todo", ExecutionQueueIntent::NotReady, - false, ), status_program_active_node("node-active", "issue-active", "PUB-945", "In Progress"), ], @@ -376,16 +380,13 @@ fn assert_program_readback_summary(program: &OperatorExecutionProgramStatus) { assert_eq!(program.status, "blocked"); assert_eq!(program.intake_kind.as_deref(), Some("issue_batch_intake")); assert_eq!(program.public_summary.as_deref(), Some("Coordinate status readback work.")); - assert_eq!(program.ready_count, 1); - assert_eq!(program.queued_count, 1); + assert_eq!(program.ready_count, 2); + assert_eq!(program.queued_count, 0); assert_eq!(program.blocked_count, 1); assert_eq!(program.held_count, 2); assert_eq!(program.active_count, 1); assert_eq!(program.stale_count, 0); - assert_eq!(program.queue_label_eligible_count, 2); - assert_eq!(program.queue_label_apply_count, 1); - assert_eq!(program.queue_label_retain_count, 1); - assert_eq!(program.queue_label_remove_count, 1); + assert_eq!(program.dispatchable_count, 2); assert_eq!( program.mapped_issue_identifiers, vec![ @@ -414,13 +415,11 @@ fn assert_program_readback_json(program_json: &Value) { assert_eq!(program_json["status"], "blocked"); assert_eq!(program_json["intake_kind"], "issue_batch_intake"); assert_eq!(program_json["public_summary"], "Coordinate status readback work."); - assert_eq!(program_json["ready_count"], 1); - assert_eq!(program_json["queued_count"], 1); + assert_eq!(program_json["ready_count"], 2); + assert_eq!(program_json["queued_count"], 0); assert_eq!(program_json["active_count"], 1); assert_eq!(program_json["held_count"], 2); - assert_eq!(program_json["queue_label_apply_count"], 1); - assert_eq!(program_json["queue_label_retain_count"], 1); - assert_eq!(program_json["queue_label_remove_count"], 1); + assert_eq!(program_json["dispatchable_count"], 2); assert!(program_json.get("contract").is_none()); assert!(program_json.get("graph").is_none()); } @@ -440,10 +439,10 @@ fn assert_program_node_readbacks( let held_node = node_by_issue.get("PUB-944").expect("held node should render"); let active_node = node_by_issue.get("PUB-945").expect("active node should render"); - assert_eq!(ready_node.queue_label_action.as_deref(), Some("apply")); - assert_eq!(queued_node.queue_label_action.as_deref(), Some("retain")); + assert_eq!(ready_node.dispatch_action.as_deref(), Some("dispatch")); + assert_eq!(queued_node.dispatch_action.as_deref(), Some("dispatch")); assert_eq!(blocked_node.lifecycle_state, "blocked"); - assert_eq!(blocked_node.queue_label_action.as_deref(), Some("remove")); + assert_eq!(blocked_node.dispatch_action.as_deref(), None); assert!(blocked_node.reason_codes.contains(&String::from("dependency_not_terminal"))); assert_eq!( blocked_node.reasons, @@ -451,7 +450,7 @@ fn assert_program_node_readbacks( ); assert!(blocked_node.next_action.contains("Execution Program dependency plan")); assert_eq!(held_node.lifecycle_state, "mapped"); - assert!(held_node.reason_codes.contains(&String::from("queue_intent_not_ready"))); + assert!(held_node.reason_codes.contains(&String::from("dispatch_intent_not_ready"))); assert_eq!(active_node.lifecycle_state, "active"); assert!(active_node.reason_codes.contains(&String::from("mapped_issue_active_label_present"))); @@ -464,7 +463,7 @@ fn assert_program_node_readbacks( assert_eq!(node_json["lifecycle_state"], "active"); assert_eq!(node_json["readiness_state"], "blocked"); - assert_eq!(node_json["queue_label_action"], serde_json::Value::Null); + assert_eq!(node_json["dispatch_action"], serde_json::Value::Null); } #[test] @@ -482,7 +481,6 @@ fn operator_status_json_surfaces_missing_contract_program_recovery() { "PUB-946", "Todo", ExecutionQueueIntent::ReadyToQueue, - false, )], ) .expect("program should build"); @@ -530,7 +528,7 @@ fn operator_status_json_surfaces_missing_contract_program_recovery() { assert_eq!(program_json["node_readbacks"][0]["reason_codes"][0], "source_decision_contract_missing"); assert_eq!( program_json["node_readbacks"][0]["next_action"], - "Restore or supersede the source Decision Contract before queueing this program." + "Restore or supersede the source Decision Contract before dispatching this program." ); assert!(program_json.get("contract").is_none()); assert!(program_json.get("decision_contract").is_none()); @@ -542,14 +540,8 @@ fn status_program_node( issue_identifier: &str, issue_state: &str, queue_intent: ExecutionQueueIntent, - program_owned_queue_label: bool, ) -> ExecutionProgramNode { - let mapping = status_program_issue_mapping( - issue_id, - issue_identifier, - issue_state, - program_owned_queue_label, - ); + let mapping = status_program_issue_mapping(issue_id, issue_identifier, issue_state); ExecutionProgramNode::new( node_id, @@ -572,7 +564,6 @@ fn status_program_node_with_dependency( issue_identifier: &str, issue_state: &str, dependency_identifier: &str, - program_owned_queue_label: bool, ) -> ExecutionProgramNode { status_program_node( node_id, @@ -580,7 +571,6 @@ fn status_program_node_with_dependency( issue_identifier, issue_state, ExecutionQueueIntent::ReadyToQueue, - program_owned_queue_label, ) .with_dependencies([ExecutionProgramDependency::new(dependency_identifier) .expect("dependency should build")]) @@ -591,16 +581,9 @@ fn status_program_issue_mapping( issue_id: &str, issue_identifier: &str, issue_state: &str, - program_owned_queue_label: bool, ) -> ExecutionLinearIssueMapping { - let mapping = ExecutionLinearIssueMapping::new(issue_id, issue_identifier, issue_state) - .expect("mapping should build"); - - if program_owned_queue_label { - mapping.with_program_owned_queue_label(true) - } else { - mapping - } + ExecutionLinearIssueMapping::new(issue_id, issue_identifier, issue_state) + .expect("mapping should build") } fn status_program_active_node( @@ -609,7 +592,7 @@ fn status_program_active_node( issue_identifier: &str, issue_state: &str, ) -> ExecutionProgramNode { - let mapping = status_program_issue_mapping(issue_id, issue_identifier, issue_state, false) + let mapping = status_program_issue_mapping(issue_id, issue_identifier, issue_state) .with_active_label(true); ExecutionProgramNode::new( diff --git a/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs b/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs index 079305cb6..0e1133d1e 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs @@ -12,8 +12,8 @@ use crate::execution_program::ExecutionProgram; use crate::execution_program::ExecutionProgramEvaluation; use crate::execution_program::ExecutionProgramNodeStage; use crate::execution_program::ExecutionProgramReadinessContext; +use crate::execution_program::ExecutionDispatchAction; use crate::execution_program::ExecutionQueueIntent; -use crate::execution_program::ExecutionQueueLabelAction; use crate::execution_program::ExecutionWorkflowPolicy; use crate::loop_contract::DecisionContract; use crate::loop_contract::DecisionContractStatus; @@ -131,16 +131,14 @@ impl LoopScenarioHarness { (contract, policy, evaluation) } - fn assert_queue_shaping( + fn assert_direct_dispatch_shaping( &self, - policy: &ExecutionWorkflowPolicy, evaluation: &ExecutionProgramEvaluation, ) { let summary = evaluation.operator_summary(); - assert_eq!(policy.queue_label(), "decodex:queued:decodex"); assert_eq!(evaluation.ready_node_ids(), vec!["node-ready"]); - assert_eq!(evaluation.startable_node_ids(), vec!["node-ready"]); + assert_eq!(evaluation.dispatchable_node_ids(), vec!["node-ready"]); assert_eq!(summary.ready_count, 1); assert_eq!(summary.queued_count, 0); assert_eq!(summary.blocked_count, 1); @@ -148,18 +146,18 @@ impl LoopScenarioHarness { assert_eq!(summary.held_count, 2); assert_eq!(summary.active_count, 1); assert_eq!(summary.needs_attention_count, 0); - assert_eq!(summary.queue_label_eligible_count, 1); + assert_eq!(summary.dispatchable_count, 1); assert_eq!( - loop_scenario_queue_action(evaluation, "node-ready"), - Some(ExecutionQueueLabelAction::Apply) + loop_scenario_dispatch_action(evaluation, "node-ready"), + Some(ExecutionDispatchAction::Dispatch) ); assert_eq!( - loop_scenario_queue_action(evaluation, "node-uncovered-direction"), - Some(ExecutionQueueLabelAction::Remove) + loop_scenario_dispatch_action(evaluation, "node-uncovered-direction"), + None ); assert_eq!( - loop_scenario_queue_action(evaluation, "node-active"), - Some(ExecutionQueueLabelAction::Remove) + loop_scenario_dispatch_action(evaluation, "node-active"), + None ); } @@ -412,9 +410,9 @@ impl LoopScenarioHarness { fn research_to_execution_loop_scenario_shapes_ready_work_and_records_feedback() { let harness = LoopScenarioHarness::new(); let contract = harness.assert_latent_research_stays_non_executable(); - let (contract, policy, evaluation) = harness.promote_and_evaluate_program(contract); + let (contract, _policy, evaluation) = harness.promote_and_evaluate_program(contract); - harness.assert_queue_shaping(&policy, &evaluation); + harness.assert_direct_dispatch_shaping(&evaluation); harness.record_review_guardrail_and_assert_harness_feedback(contract); } @@ -573,7 +571,6 @@ fn loop_scenario_program_nodes() -> Vec Vec Vec crate::execution_program::ExecutionProgramNode "Retain handoff ownership without queueing", ExecutionQueueIntent::Active, "XY-864", - true, ) .with_linear_issue( ExecutionLinearIssueMapping::new("linear-node-active", "XY-864", "Todo") .expect("issue mapping should build") - .with_program_owned_queue_label(true) .with_active_label(true), ) .expect("active issue should attach") @@ -735,7 +728,6 @@ fn loop_scenario_node( objective: &str, queue_intent: ExecutionQueueIntent, issue_identifier: &str, - has_queue_label: bool, ) -> crate::execution_program::ExecutionProgramNode { crate::execution_program::ExecutionProgramNode::new(node_id, stage, objective, queue_intent) .expect("node should build") @@ -762,20 +754,19 @@ fn loop_scenario_node( "Todo", ) .expect("issue mapping should build") - .with_program_owned_queue_label(has_queue_label), ) .expect("issue mapping should attach") } -fn loop_scenario_queue_action( +fn loop_scenario_dispatch_action( evaluation: &ExecutionProgramEvaluation, node_id: &str, -) -> Option { +) -> Option { evaluation .nodes() .iter() .find(|node| node.node_id() == node_id) .unwrap_or_else(|| panic!("missing node evaluation `{node_id}`")) - .queue_label_action() + .dispatch_action() } } diff --git a/apps/decodex/src/orchestrator/tests/runtime/program_intake_dogfood.rs b/apps/decodex/src/orchestrator/tests/runtime/program_intake_dogfood.rs index 7a288ec39..52c9d796d 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/program_intake_dogfood.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/program_intake_dogfood.rs @@ -9,7 +9,7 @@ use crate::{ program_intake::{self, GoalIntakeRunRequest}, state::StateStore, tracker::{ - self, IssueTracker, TrackerComment, TrackerIssue, TrackerIssueBlocker, + IssueTracker, TrackerComment, TrackerIssue, TrackerIssueBlocker, TrackerIssueBriefUpdate, TrackerIssueCreate, TrackerLabel, TrackerState, TrackerTeam, }, workflow::WorkflowDocument, @@ -203,10 +203,9 @@ impl DogfoodTracker { } #[test] -fn issue_batch_intake_apply_reconcile_unlock_and_status_readback_is_end_to_end() { +fn issue_batch_intake_apply_direct_dispatch_unlock_and_status_readback_is_end_to_end() { let (_temp_dir, config, workflow) = super::temp_project_layout(); let store = StateStore::open_in_memory().expect("state store should open"); - let queue_label = tracker::automation_queue_label(config.service_id()); let dependency_todo = dogfood_issue(config.service_id(), "issue-dependency", "PUB-942A", "Todo", &[]); let dependent_todo = with_blocker( dogfood_issue(config.service_id(), "issue-dependent", "PUB-942B", "Todo", &[]), @@ -253,10 +252,10 @@ fn issue_batch_intake_apply_reconcile_unlock_and_status_readback_is_end_to_end() 2 ); - assert_initial_issue_batch_scan(&tracker, &config, &workflow, &store, &dependency_todo); + assert_initial_issue_batch_dispatch(&tracker, &config, &workflow, &store, &dependency_todo); let mut dependency_done = - dogfood_issue(config.service_id(), "issue-dependency", "PUB-942A", "Done", &[queue_label.as_str()]); + dogfood_issue(config.service_id(), "issue-dependency", "PUB-942A", "Done", &[]); dependency_done.id.clone_from(&dependency_todo.id); tracker.upsert_issue(dependency_done); @@ -270,25 +269,33 @@ fn issue_batch_intake_apply_reconcile_unlock_and_status_readback_is_end_to_end() dependent_unblocked.id.clone_from(&dependent_todo.id); tracker.upsert_issue(dependent_unblocked); - assert_unlocked_issue_batch_scan(&tracker, &config, &workflow, &store, &dependency_todo, &dependent_todo); + assert_unlocked_issue_batch_dispatch(&tracker, &config, &workflow, &store, &dependent_todo); } -fn assert_initial_issue_batch_scan( +fn assert_initial_issue_batch_dispatch( tracker: &DogfoodTracker, config: &ServiceConfig, workflow: &WorkflowDocument, store: &StateStore, dependency_todo: &TrackerIssue, ) { - let first_scan = - orchestrator::reconcile_execution_program_queue_labels(tracker, config, workflow, store) - .expect("first program reconciliation should queue only the ready node"); - - assert_eq!(first_scan.labels_applied, 1); - assert_eq!( - tracker.label_additions(), - vec![(dependency_todo.id.clone(), vec![String::from("label-queued")])] - ); + let first_selection = orchestrator::select_execution_program_run_candidate_with_summary( + tracker, + config, + workflow, + store, + &[], + ) + .expect("first program scheduler selection should choose only the ready node"); + let selected = first_selection + .selected + .expect("ready dependency node should be selected for direct dispatch"); + + assert_eq!(first_selection.summary.dispatchable_nodes, 1); + assert_eq!(selected.issue.id, dependency_todo.id); + assert_eq!(selected.dispatch_mode, orchestrator::IssueDispatchMode::Program); + assert!(tracker.label_additions().is_empty()); + assert!(tracker.label_removals().is_empty()); let first_snapshot = orchestrator::build_live_operator_status_snapshot(tracker, config, workflow, store, 10) @@ -298,7 +305,8 @@ fn assert_initial_issue_batch_scan( assert_eq!(first_program.status, "blocked"); assert_eq!(first_program.intake_kind.as_deref(), Some("issue_batch_intake")); - assert_eq!(first_program.queued_count, 1); + assert_eq!(first_program.ready_count, 1); + assert_eq!(first_program.queued_count, 0); assert_eq!(first_program.blocked_count, 1); assert!( first_program @@ -308,28 +316,30 @@ fn assert_initial_issue_batch_scan( ); } -fn assert_unlocked_issue_batch_scan( +fn assert_unlocked_issue_batch_dispatch( tracker: &DogfoodTracker, config: &ServiceConfig, workflow: &WorkflowDocument, store: &StateStore, - dependency_todo: &TrackerIssue, dependent_todo: &TrackerIssue, ) { - let second_scan = - orchestrator::reconcile_execution_program_queue_labels(tracker, config, workflow, store) - .expect("second program reconciliation should unlock the downstream node"); - - assert_eq!(second_scan.labels_applied, 1); - assert_eq!(second_scan.labels_removed, 1); - assert!(tracker.label_additions().contains(&( - dependent_todo.id.clone(), - vec![String::from("label-queued")] - ))); - assert_eq!( - tracker.label_removals(), - vec![(dependency_todo.id.clone(), vec![String::from("label-queued")])] - ); + let second_selection = orchestrator::select_execution_program_run_candidate_with_summary( + tracker, + config, + workflow, + store, + &[], + ) + .expect("second program scheduler selection should unlock the downstream node"); + let selected = second_selection + .selected + .expect("dependent node should be selected for direct dispatch"); + + assert_eq!(second_selection.summary.dispatchable_nodes, 1); + assert_eq!(selected.issue.id, dependent_todo.id); + assert_eq!(selected.dispatch_mode, orchestrator::IssueDispatchMode::Program); + assert!(tracker.label_additions().is_empty()); + assert!(tracker.label_removals().is_empty()); let second_snapshot = orchestrator::build_live_operator_status_snapshot(tracker, config, workflow, store, 10) @@ -338,9 +348,10 @@ fn assert_unlocked_issue_batch_scan( second_snapshot.execution_programs.first().expect("program status should remain visible"); let rendered_status = orchestrator::render_operator_status(&second_snapshot); - assert_eq!(second_program.status, "queued", "{rendered_status}"); + assert_eq!(second_program.status, "ready", "{rendered_status}"); assert_eq!(second_program.completed_count, 1); - assert_eq!(second_program.queued_count, 1); + assert_eq!(second_program.ready_count, 1); + assert_eq!(second_program.queued_count, 0); assert_eq!(second_program.blocked_count, 0); assert!(rendered_status.contains("Execution Programs")); assert!(rendered_status.contains("mapped_issues=PUB-942A, PUB-942B")); @@ -348,7 +359,7 @@ fn assert_unlocked_issue_batch_scan( } #[test] -fn goal_intake_rejects_latent_then_apply_reconcile_and_status_readback_is_end_to_end() { +fn goal_intake_rejects_latent_then_apply_direct_dispatch_and_status_readback_is_end_to_end() { let (_temp_dir, config, workflow) = super::temp_project_layout(); let store = StateStore::open_in_memory().expect("state store should open"); let source_issue = dogfood_issue(config.service_id(), "issue-source", "PUB-940A", "Todo", &[]); @@ -409,11 +420,22 @@ fn goal_intake_rejects_latent_then_apply_reconcile_and_status_readback_is_end_to assert_eq!(apply_report.issues.len(), 2); assert!(tracker.label_additions().is_empty()); - let scan = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("goal-intake program reconciliation should queue generated ready nodes"); + let selection = orchestrator::select_execution_program_run_candidate_with_summary( + &tracker, + &config, + &workflow, + &store, + &[], + ) + .expect("goal-intake program scheduler selection should find generated ready nodes"); + let selected = selection + .selected + .expect("one generated issue should be selected for direct dispatch"); - assert_eq!(scan.labels_applied, 2); + assert_eq!(selection.summary.dispatchable_nodes, 2); + assert_eq!(selected.dispatch_mode, orchestrator::IssueDispatchMode::Program); + assert!(tracker.label_additions().is_empty()); + assert!(tracker.label_removals().is_empty()); let snapshot = orchestrator::build_live_operator_status_snapshot(&tracker, &config, &workflow, &store, 10) @@ -421,15 +443,15 @@ fn goal_intake_rejects_latent_then_apply_reconcile_and_status_readback_is_end_to let program = snapshot.execution_programs.first().expect("goal program should surface"); let rendered_status = orchestrator::render_operator_status(&snapshot); - assert_eq!(program.status, "queued"); + assert_eq!(program.status, "ready"); assert_eq!(program.source_contract_id.as_deref(), Some(accepted.contract_id())); assert_eq!(program.intake_kind.as_deref(), Some("goal_intake")); assert_eq!( program.public_summary.as_deref(), Some("Dogfood accepted goal intake through generated issues.") ); - assert_eq!(program.queued_count, 2); - assert_eq!(program.queue_label_retain_count, 2); + assert_eq!(program.ready_count, 2); + assert_eq!(program.queued_count, 0); assert!(rendered_status.contains("source_contract_id: dogfood-goal-contract")); assert!(!rendered_status.contains("private_evidence")); assert!(!rendered_status.contains("research_evidence")); diff --git a/apps/decodex/src/orchestrator/tests/runtime/program_reconciler.rs b/apps/decodex/src/orchestrator/tests/runtime/program_reconciler.rs index b0a2bdef8..e505e5bb6 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/program_reconciler.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/program_reconciler.rs @@ -15,16 +15,10 @@ use crate::orchestrator::tests::{ }; #[test] -fn applies_ready_queue_label_and_repeated_reconcile_is_idempotent() { +fn selects_ready_node_for_direct_program_dispatch_without_queue_label_mutation() { 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-ready", "PUB-201", "Todo", &[]); - let queued_issue = program_reconciler_issue( - "issue-ready", - "PUB-201", - "Todo", - &[program_reconciler_queue_label().as_str()], - ); store .upsert_execution_program( @@ -38,30 +32,22 @@ fn applies_ready_queue_label_and_repeated_reconcile_is_idempotent() { .expect("program should persist"); let tracker = FakeTracker::new(vec![issue.clone()]); - let summary = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("ready reconcile should succeed"); - - assert_eq!(summary.labels_applied, 1); - assert_eq!( - tracker.label_additions.borrow().as_slice(), - &[(issue.id.clone(), vec![String::from("label-queued")])] - ); - - assert_program_owned_queue_label(&store, config.service_id(), &issue, true); - - let tracker = FakeTracker::new(vec![queued_issue.clone()]); - let summary = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("idempotent reconcile should succeed"); + let selection = orchestrator::select_execution_program_run_candidate_with_summary( + &tracker, + &config, + &workflow, + &store, + &[], + ) + .expect("ready program dispatch selection should succeed"); + let selected = selection.selected.expect("ready node should be selected"); - assert_eq!(summary.labels_applied, 0); - assert_eq!(summary.labels_removed, 0); - assert_eq!(summary.labels_retained, 1); + assert_eq!(selection.summary.programs_evaluated, 1); + assert_eq!(selection.summary.dispatchable_nodes, 1); + assert_eq!(selected.issue.id, issue.id); + assert_eq!(selected.dispatch_mode, orchestrator::IssueDispatchMode::Program); assert!(tracker.label_additions.borrow().is_empty()); assert!(tracker.label_removals.borrow().is_empty()); - - assert_program_owned_queue_label(&store, config.service_id(), &queued_issue, true); } #[test] @@ -95,23 +81,33 @@ fn unlocks_downstream_node_when_dependency_reaches_terminal_state() { .expect("program should persist"); let tracker = FakeTracker::new(vec![dependency_todo, dependent.clone()]); - let blocked_summary = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("blocked reconcile should succeed"); + let blocked = orchestrator::select_execution_program_run_candidate_with_summary( + &tracker, + &config, + &workflow, + &store, + &[], + ) + .expect("blocked program dispatch selection should succeed"); - assert_eq!(blocked_summary.labels_applied, 0); + assert!(blocked.selected.is_none()); + assert_eq!(blocked.summary.dispatchable_nodes, 0); assert!(tracker.label_additions.borrow().is_empty()); let tracker = FakeTracker::new(vec![dependency_done, dependent.clone()]); - let ready_summary = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("unlock reconcile should succeed"); - - assert_eq!(ready_summary.labels_applied, 1); - assert_eq!( - tracker.label_additions.borrow().as_slice(), - &[(dependent.id.clone(), vec![String::from("label-queued")])] - ); + let ready = orchestrator::select_execution_program_run_candidate_with_summary( + &tracker, + &config, + &workflow, + &store, + &[], + ) + .expect("unlocked program dispatch selection should succeed"); + let selected = ready.selected.expect("dependent should be selected"); + + assert_eq!(ready.summary.dispatchable_nodes, 1); + assert_eq!(selected.issue.id, dependent.id); + assert!(tracker.label_additions.borrow().is_empty()); } #[test] @@ -148,16 +144,22 @@ fn active_conflict_domain_holds_peer_node() { .expect("program should persist"); let tracker = FakeTracker::new(vec![active_issue, peer_issue]); - let summary = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("conflict reconcile should succeed"); + let selection = orchestrator::select_execution_program_run_candidate_with_summary( + &tracker, + &config, + &workflow, + &store, + &[], + ) + .expect("conflict program dispatch selection should succeed"); - assert_eq!(summary.labels_applied, 0); + assert!(selection.selected.is_none()); + assert_eq!(selection.summary.dispatchable_nodes, 0); assert!(tracker.label_additions.borrow().is_empty()); } #[test] -fn needs_attention_removes_only_program_owned_queue_label() { +fn needs_attention_node_is_not_selected_even_with_stale_queue_label() { let (_temp_dir, config, workflow) = temp_project_layout(); let store = StateStore::open_in_memory().expect("state store should open"); let queue_label = program_reconciler_queue_label(); @@ -175,56 +177,56 @@ fn needs_attention_removes_only_program_owned_queue_label() { "node-attention", &issue, ExecutionQueueIntent::ReadyToQueue, - true, )]), ) .expect("program should persist"); - assert_program_owned_queue_label(&store, config.service_id(), &issue, true); - let tracker = FakeTracker::new(vec![issue.clone()]); - let summary = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("attention reconcile should succeed"); - - assert_eq!(summary.labels_removed, 1); - assert_eq!( - tracker.label_removals.borrow().as_slice(), - &[(issue.id.clone(), vec![String::from("label-queued")])] - ); + let selection = orchestrator::select_execution_program_run_candidate_with_summary( + &tracker, + &config, + &workflow, + &store, + &[], + ) + .expect("attention program dispatch selection should succeed"); - assert_program_owned_queue_label(&store, config.service_id(), &issue, false); + assert!(selection.selected.is_none()); + assert_eq!(selection.summary.dispatchable_nodes, 0); + assert!(tracker.label_additions.borrow().is_empty()); + assert!(tracker.label_removals.borrow().is_empty()); } #[test] -fn human_owned_queue_label_is_not_removed_when_node_blocks() { +fn excluded_ready_issue_is_not_selected() { let (_temp_dir, config, workflow) = temp_project_layout(); let store = StateStore::open_in_memory().expect("state store should open"); - let queue_label = program_reconciler_queue_label(); - let issue = - program_reconciler_issue("issue-human", "PUB-207", "In Progress", &[queue_label.as_str()]); + let issue = program_reconciler_issue("issue-excluded", "PUB-207", "Todo", &[]); store .upsert_execution_program( config.service_id(), program_reconciler_program(vec![program_reconciler_node_with_mapping( - "node-human", + "node-excluded", &issue, ExecutionQueueIntent::ReadyToQueue, - false, )]), ) .expect("program should persist"); let tracker = FakeTracker::new(vec![issue.clone()]); - let summary = - orchestrator::reconcile_execution_program_queue_labels(&tracker, &config, &workflow, &store) - .expect("human-owned reconcile should succeed"); + let selection = orchestrator::select_execution_program_run_candidate_with_summary( + &tracker, + &config, + &workflow, + &store, + &[issue.id.as_str()], + ) + .expect("excluded program dispatch selection should succeed"); - assert_eq!(summary.labels_removed, 0); + assert!(selection.selected.is_none()); + assert_eq!(selection.summary.dispatchable_nodes, 0); assert!(tracker.label_removals.borrow().is_empty()); - - assert_program_owned_queue_label(&store, config.service_id(), &issue, false); } fn program_reconciler_program(nodes: Vec) -> ExecutionProgram { @@ -243,16 +245,15 @@ fn program_reconciler_node( issue: &TrackerIssue, queue_intent: ExecutionQueueIntent, ) -> ExecutionProgramNode { - program_reconciler_node_with_mapping(node_id, issue, queue_intent, false) + program_reconciler_node_with_mapping(node_id, issue, queue_intent) } fn program_reconciler_node_with_mapping( node_id: &str, issue: &TrackerIssue, queue_intent: ExecutionQueueIntent, - program_owned_queue_label: bool, ) -> ExecutionProgramNode { - let mapping = program_reconciler_mapping(issue, program_owned_queue_label); + let mapping = program_reconciler_mapping(issue); ExecutionProgramNode::new( node_id, @@ -269,21 +270,9 @@ fn program_reconciler_node_with_mapping( .expect("issue mapping should attach") } -fn program_reconciler_mapping( - issue: &TrackerIssue, - program_owned_queue_label: bool, -) -> ExecutionLinearIssueMapping { - let mut mapping = - ExecutionLinearIssueMapping::new(&issue.id, &issue.identifier, &issue.state.name) - .expect("mapping should build"); - - mapping = if program_owned_queue_label { - mapping.with_program_owned_queue_label(true) - } else { - mapping.with_queue_label(issue.has_label(&program_reconciler_queue_label())) - }; - - mapping +fn program_reconciler_mapping(issue: &TrackerIssue) -> ExecutionLinearIssueMapping { + ExecutionLinearIssueMapping::new(&issue.id, &issue.identifier, &issue.state.name) + .expect("mapping should build") .with_active_label(issue.has_label(&tracker::automation_active_label("pubfi"))) .with_opt_out_label(issue.has_label("decodex:manual-only")) .with_needs_attention_label(issue.has_label("decodex:needs-attention")) @@ -325,25 +314,4 @@ fn program_reconciler_queue_label() -> String { tracker::automation_queue_label("pubfi") } -fn assert_program_owned_queue_label( - store: &StateStore, - service_id: &str, - issue: &TrackerIssue, - expected_present: bool, -) { - let records = store - .program_queue_label_ownership_for_issue( - service_id, - &issue.id, - &program_reconciler_queue_label(), - ) - .expect("ownership records should load"); - - assert_eq!( - !records.is_empty(), - expected_present, - "program-owned queue-label evidence mismatch for {}", - issue.identifier - ); -} } diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 009aa43ba..cde3fd083 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -41,6 +41,7 @@ trait PullRequestReviewStateInspector { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum IssueDispatchMode { Normal, + Program, Retry, ReviewRepair, Closeout, @@ -49,6 +50,7 @@ impl IssueDispatchMode { fn as_str(self) -> &'static str { match self { Self::Normal => "normal", + Self::Program => "program", Self::Retry => "retry", Self::ReviewRepair => "review_repair", Self::Closeout => "closeout", @@ -70,6 +72,11 @@ impl IssueDispatchMode { issue_passes_dispatch_policy(tracker, issue, workflow, &queue_label, false) }, + Self::Program => { + let queue_label = tracker::automation_queue_label(project.service_id()); + + issue_passes_dispatch_policy(tracker, issue, workflow, &queue_label, true) + }, Self::Retry => issue_passes_retry_dispatch_policy( tracker, issue, @@ -1312,13 +1319,7 @@ struct OperatorExecutionProgramStatus { completed_count: usize, stale_count: usize, superseded_count: usize, - queue_label_eligible_count: usize, - #[serde(default)] - queue_label_apply_count: usize, - #[serde(default)] - queue_label_retain_count: usize, - #[serde(default)] - queue_label_remove_count: usize, + dispatchable_count: usize, mapped_issue_identifiers: Vec, #[serde(default)] node_readbacks: Vec, @@ -1330,8 +1331,6 @@ impl OperatorExecutionProgramStatus { summary: ExecutionProgramOperatorSummary, evaluation: &ExecutionProgramEvaluation, ) -> Self { - let (queue_label_apply_count, queue_label_retain_count, queue_label_remove_count) = - operator_execution_program_queue_label_action_counts(evaluation); let program_intake_plan = record.program().program_intake_plan(); Self { @@ -1352,10 +1351,7 @@ impl OperatorExecutionProgramStatus { completed_count: summary.completed_count, stale_count: summary.stale_count, superseded_count: summary.superseded_count, - queue_label_eligible_count: summary.queue_label_eligible_count, - queue_label_apply_count, - queue_label_retain_count, - queue_label_remove_count, + dispatchable_count: summary.dispatchable_count, mapped_issue_identifiers: summary.mapped_issue_identifiers, node_readbacks: evaluation .nodes() @@ -1394,10 +1390,7 @@ impl OperatorExecutionProgramStatus { completed_count: 0, stale_count: node_count, superseded_count: 0, - queue_label_eligible_count: 0, - queue_label_apply_count: 0, - queue_label_retain_count: 0, - queue_label_remove_count: 0, + dispatchable_count: 0, mapped_issue_identifiers: operator_execution_program_mapped_issue_identifiers(record), node_readbacks: operator_execution_program_missing_contract_nodes(record), readback_warning: Some(String::from("source_decision_contract_missing")), @@ -1411,7 +1404,7 @@ struct OperatorExecutionProgramNodeStatus { readiness_state: String, issue_identifier: Option, issue_state: Option, - queue_label_action: Option, + dispatch_action: Option, reason_codes: Vec, reasons: Vec, next_action: String, @@ -2305,33 +2298,10 @@ fn operator_execution_program_unknown_status() -> String { String::from("unknown") } -fn operator_execution_program_queue_label_action_counts( - evaluation: &ExecutionProgramEvaluation, -) -> (usize, usize, usize) { - let mut apply_count = 0; - let mut retain_count = 0; - let mut remove_count = 0; - - for node in evaluation.nodes() { - match node.queue_label_action() { - Some(ExecutionQueueLabelAction::Apply) => apply_count += 1, - Some(ExecutionQueueLabelAction::Retain) => { - retain_count += 1 - }, - Some(ExecutionQueueLabelAction::Remove) => { - remove_count += 1 - }, - None => {}, - } - } - - (apply_count, retain_count, remove_count) -} - fn operator_execution_program_node_should_render( node: &ExecutionNodeEvaluation, ) -> bool { - node.queue_label_action().is_some() + node.dispatch_action().is_some() || matches!( node.lifecycle_state(), crate::execution_program::ExecutionProgramNodeLifecycleState::Active @@ -2360,7 +2330,7 @@ fn operator_execution_program_node_readback( readiness_state: node.state().as_str().to_owned(), issue_identifier: issue.map(|issue| issue.issue_identifier().to_owned()), issue_state: issue.map(|issue| issue.issue_state().to_owned()), - queue_label_action: node.queue_label_action().map(|action| action.as_str().to_owned()), + dispatch_action: node.dispatch_action().map(|action| action.as_str().to_owned()), next_action: operator_execution_program_node_next_action(node, &reason_codes), reason_codes, reasons, @@ -2382,11 +2352,11 @@ fn operator_execution_program_missing_contract_nodes( readiness_state: String::from("stale"), issue_identifier: issue.map(|issue| issue.issue_identifier().to_owned()), issue_state: issue.map(|issue| issue.issue_state().to_owned()), - queue_label_action: None, + dispatch_action: None, reason_codes: vec![String::from("source_decision_contract_missing")], reasons: vec![String::from("source Decision Contract is missing")], next_action: String::from( - "Restore or supersede the source Decision Contract before queueing this program.", + "Restore or supersede the source Decision Contract before dispatching this program.", ), } }) @@ -2422,14 +2392,14 @@ fn operator_execution_program_reason_codes(reasons: &[String]) -> Vec { fn operator_execution_program_reason_code(reason: &str) -> &'static str { if reason == "node no longer matches the accepted Decision Contract" { "accepted_contract_mismatch" - } else if reason == "node queue intent is not-ready" { - "queue_intent_not_ready" - } else if reason == "node queue intent is paused" { - "queue_intent_paused" + } else if reason == "node dispatch intent is not-ready" { + "dispatch_intent_not_ready" + } else if reason == "node dispatch intent is paused" { + "dispatch_intent_paused" } else if reason == "node already has an active lane" { "active_lane_present" - } else if reason == "node queue intent is terminal" { - "queue_intent_terminal" + } else if reason == "node dispatch intent is terminal" { + "dispatch_intent_terminal" } else if reason == "node is ready for normal Linear issue execution" { "ready_for_linear_execution" } else if reason == "node has no acceptance expectations" { @@ -2481,7 +2451,7 @@ fn operator_execution_program_node_next_action( | crate::execution_program::ExecutionProgramNodeLifecycleState::Superseded ) { return String::from( - "Refresh or supersede the accepted Decision Contract before queueing this program.", + "Refresh or supersede the accepted Decision Contract before dispatching this program.", ); } if reason_codes.iter().any(|code| code == "dependency_not_terminal") { @@ -2496,7 +2466,7 @@ fn operator_execution_program_node_next_action( || reason_codes.iter().any(|code| code == "mapped_issue_needs_attention") { return String::from( - "Resolve the mapped issue's needs-attention stop before queueing this node.", + "Resolve the mapped issue's needs-attention stop before dispatching this node.", ); } if matches!( @@ -2504,37 +2474,18 @@ fn operator_execution_program_node_next_action( crate::execution_program::ExecutionProgramNodeLifecycleState::Active ) { return String::from( - "Wait for the active lane or recover its retained state before queueing this node.", + "Wait for the active lane or recover its retained state before dispatching this node.", ); } - if matches!( - node.queue_label_action(), - Some(crate::execution_program::ExecutionQueueLabelAction::Remove) - ) { - return String::from( - "Allow the program reconciler to remove its owned queue label on the next Linear scan.", - ); - } - if matches!( - node.queue_label_action(), - Some(crate::execution_program::ExecutionQueueLabelAction::Apply) - ) { - return String::from( - "Wait for the next Linear scan or request a targeted scan to apply the service queue label.", - ); - } - if matches!( - node.queue_label_action(), - Some(crate::execution_program::ExecutionQueueLabelAction::Retain) - ) { - return String::from("Issue is already queued by the program reconciler."); + if node.dispatch_action().is_some() { + return String::from("The program scheduler can dispatch this node directly."); } if matches!( node.lifecycle_state(), crate::execution_program::ExecutionProgramNodeLifecycleState::Planned | crate::execution_program::ExecutionProgramNodeLifecycleState::Mapped ) { - return String::from("Map, promote, or unpause this intake node before queueing it."); + return String::from("Map, promote, or unpause this intake node before dispatching it."); } if matches!( node.lifecycle_state(), diff --git a/apps/decodex/src/plugin_surface_tests.rs b/apps/decodex/src/plugin_surface_tests.rs index cfeff2ed4..501c0f2f4 100644 --- a/apps/decodex/src/plugin_surface_tests.rs +++ b/apps/decodex/src/plugin_surface_tests.rs @@ -63,10 +63,10 @@ fn packaged_skills_preserve_research_promotion_and_queue_boundaries() { assert_contains(DECODEX_SKILL, "`arrange this`"); assert_contains(DECODEX_SKILL, "`推进`"); assert_contains(DECODEX_SKILL, "Do not queue work"); - assert_contains(DECODEX_SKILL, "Queue labels are an intake signal"); + assert_contains(DECODEX_SKILL, "dispatches ready mapped nodes directly"); assert_contains(PLANNING_SKILL, "accepted Decision Contract"); assert_contains(PLANNING_SKILL, "Do not use planning to turn a plain `research X`"); - assert_contains(PLANNING_SKILL, "queue only mapped issues"); + assert_contains_normalized(PLANNING_SKILL, "scheduler directly dispatch nodes"); assert_contains(PLANNING_SKILL, "Promotion is a separate authority boundary"); assert_contains(AUTOMATION_SKILL, "Automation starts only after execution authority exists"); assert_contains_normalized( @@ -74,7 +74,8 @@ fn packaged_skills_preserve_research_promotion_and_queue_boundaries() { "latent research must not dispatch retained lanes", ); assert_contains(AUTOMATION_SKILL, "accepted/promoted Decision Contract"); - assert_contains(AUTOMATION_SKILL, "blocked, stale, paused, active, terminal"); + assert_contains_normalized(AUTOMATION_SKILL, "directly dispatch ready mapped nodes"); + assert_contains(AUTOMATION_SKILL, "Blocked, stale, paused, active, terminal"); assert_contains_normalized(LABELS_SKILL, "not the user-facing research/design workflow"); assert_contains(LABELS_SKILL, "accepted/promoted Decision"); assert_contains(LABELS_SKILL, "Do not ask ordinary users to apply queue labels"); diff --git a/apps/decodex/src/program_intake.rs b/apps/decodex/src/program_intake.rs index 5b9695487..cb4da0aba 100644 --- a/apps/decodex/src/program_intake.rs +++ b/apps/decodex/src/program_intake.rs @@ -13,11 +13,10 @@ use crate::{ config::ServiceConfig, execution_program::{ ExecutionConflictDomain, ExecutionConflictDomainKind, ExecutionDependencySnapshot, - ExecutionLinearIssueMapping, ExecutionNodeEvaluation, ExecutionProgram, - ExecutionProgramDependency, ExecutionProgramEvaluation, ExecutionProgramNode, - ExecutionProgramNodeLifecycleState, ExecutionProgramNodeStage, - ExecutionProgramReadinessContext, ExecutionQueueIntent, ExecutionQueueLabelAction, - ExecutionWorkflowPolicy, + ExecutionDispatchAction, ExecutionLinearIssueMapping, ExecutionNodeEvaluation, + ExecutionProgram, ExecutionProgramDependency, ExecutionProgramEvaluation, + ExecutionProgramNode, ExecutionProgramNodeLifecycleState, ExecutionProgramNodeStage, + ExecutionProgramReadinessContext, ExecutionQueueIntent, ExecutionWorkflowPolicy, }, loop_contract::{DecisionContract, DecisionContractStatus}, orchestrator, @@ -90,8 +89,6 @@ pub(crate) struct IssueBatchIntakeReport { pub(crate) dry_run: bool, /// Whether local runtime state was persisted. pub(crate) persisted: bool, - /// Service-scoped queue label that would be used by later reconciliation. - pub(crate) queue_label: String, /// Deterministic classification counts. pub(crate) counts: IssueBatchIntakeCounts, /// Per-issue classification rows. @@ -126,8 +123,8 @@ pub(crate) struct IssueBatchIntakeIssueReport { pub(crate) classification: IssueBatchIntakeClassification, /// Queue intent stored on the internal program node, when available. pub(crate) queue_intent: Option, - /// Readiness-derived queue-label action for later reconciliation. - pub(crate) queue_label_action: Option, + /// Readiness-derived direct dispatch action. + pub(crate) dispatch_action: Option, /// Deterministic local readback reasons. pub(crate) reasons: Vec, /// Known blocker issue identifiers. @@ -151,8 +148,6 @@ pub(crate) struct GoalIntakeReport { pub(crate) applied: bool, /// Whether local runtime Program Intake records were persisted. pub(crate) persisted: bool, - /// Service-scoped queue label later reconciliation may apply. - pub(crate) queue_label: String, /// Per-issue materialization rows. pub(crate) issues: Vec, } @@ -174,8 +169,8 @@ pub(crate) struct GoalIntakeIssueReport { pub(crate) action: GoalIntakeIssueAction, /// Queue intent stored on the internal program node. pub(crate) queue_intent: String, - /// Queue-label action derived for the mapped node, when known. - pub(crate) queue_label_action: Option, + /// Direct dispatch action derived for the mapped node, when known. + pub(crate) dispatch_action: Option, /// Dependency ids or public issue identifiers required before this node can run. pub(crate) dependencies: Vec, /// Coarse conflict domains retained in the internal program. @@ -190,14 +185,11 @@ pub(crate) struct GoalIntakeIssueReport { #[derive(Clone, Debug, Eq, PartialEq)] struct IssueFacts { - has_queue_label: bool, - queue_label_program_owned: bool, has_active_label: bool, has_opt_out_label: bool, has_needs_attention_label: bool, has_generic_dispatch_briefing: bool, has_open_blockers: bool, - has_human_owned_queue_label: bool, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -381,7 +373,6 @@ where let program_id = goal_program_id(config.service_id(), contract.contract_id()); let plans = goal_issue_plans(&contract, &program_id)?; - let queue_label = tracker::automation_queue_label(config.service_id()); let linked_issues = linked_goal_issues(tracker, &contract, plans.len())?; let (issues, linked_contract) = if apply { let anchor = goal_intake_anchor( @@ -435,7 +426,6 @@ where dry_run, applied: apply, persisted: apply, - queue_label, issues: report_issues, }) } @@ -458,7 +448,6 @@ where } let issue_identifiers = normalize_issue_identifiers(issue_identifiers)?; - let queue_label = tracker::automation_queue_label(config.service_id()); let active_label = tracker::automation_active_label(config.service_id()); let policy = ExecutionWorkflowPolicy::from_workflow(config.service_id(), workflow)?; let mut resolved = BTreeMap::new(); @@ -486,15 +475,7 @@ where for identifier in &issue_identifiers { if let Some(issue) = resolved.get(identifier) { - let facts = issue_facts( - tracker, - state_store, - config.service_id(), - workflow, - issue, - &queue_label, - &active_label, - )?; + let facts = issue_facts(tracker, workflow, issue, &active_label)?; dependency_snapshots.extend(dependency_snapshots_for(issue, &supplied_node_ids)?); nodes.push(issue_node(issue, &facts, workflow, &supplied_node_ids)?); @@ -556,7 +537,6 @@ where program_id, dry_run, persisted: persist, - queue_label, counts, issues: rows, }) @@ -566,10 +546,9 @@ where pub(crate) fn render_issue_batch_intake_report(report: &IssueBatchIntakeReport) -> String { let mode = if report.persisted { "apply" } else { "dry-run" }; let mut output = format!( - "program intake {mode}: service={} program={} queue_label={} ready={} held={} blocked={} stale={} unmapped={}\n", + "program intake {mode}: service={} program={} ready={} held={} blocked={} stale={} unmapped={}\n", report.service_id, report.program_id, - report.queue_label, report.counts.ready, report.counts.held, report.counts.blocked, @@ -579,12 +558,12 @@ pub(crate) fn render_issue_batch_intake_report(report: &IssueBatchIntakeReport) for row in &report.issues { let state = row.issue_state.as_deref().unwrap_or("unmapped"); - let action = row.queue_label_action.as_deref().unwrap_or("none"); + let action = row.dispatch_action.as_deref().unwrap_or("none"); let reasons = if row.reasons.is_empty() { String::from("none") } else { row.reasons.join("; ") }; output.push_str(&format!( - "- {} classification={} state={} queue_action={} reasons={}\n", + "- {} classification={} state={} dispatch_action={} reasons={}\n", row.issue_identifier, row.classification.as_str(), state, @@ -600,29 +579,28 @@ pub(crate) fn render_issue_batch_intake_report(report: &IssueBatchIntakeReport) pub(crate) fn render_goal_intake_report(report: &GoalIntakeReport) -> String { let mode = if report.applied { "apply" } else { "dry-run" }; let mut output = format!( - "goal intake {mode}: service={} contract={} program={} queue_label={} issues={} persisted={}\n", + "goal intake {mode}: service={} contract={} program={} issues={} persisted={}\n", report.service_id, report.contract_id, report.program_id, - report.queue_label, report.issues.len(), report.persisted, ); for row in &report.issues { let issue = row.issue_identifier.as_deref().unwrap_or("new"); - let queue_action = row.queue_label_action.as_deref().unwrap_or("none"); + let dispatch_action = row.dispatch_action.as_deref().unwrap_or("none"); let dependencies = list_or_none(&row.dependencies); let conflicts = list_or_none(&row.conflict_domains); let reasons = list_or_none(&row.reasons); output.push_str(&format!( - "- {} action={} issue={} queue_intent={} queue_action={} dependencies={} conflict_domains={} reasons={}\n", + "- {} action={} issue={} queue_intent={} dispatch_action={} dependencies={} conflict_domains={} reasons={}\n", row.node_id, row.action.as_str(), issue, row.queue_intent, - queue_action, + dispatch_action, dependencies, conflicts, reasons, @@ -906,12 +884,10 @@ fn goal_issue_mapping( issue: &TrackerIssue, workflow: &WorkflowDocument, ) -> Result { - let queue_label = tracker::automation_queue_label(service_id); let active_label = tracker::automation_active_label(service_id); let tracker_policy = workflow.frontmatter().tracker(); Ok(ExecutionLinearIssueMapping::new(&issue.id, &issue.identifier, &issue.state.name)? - .with_queue_label(issue.has_label(&queue_label)) .with_active_label(issue.has_label(&active_label)) .with_opt_out_label(issue.has_label(tracker_policy.opt_out_label())) .with_needs_attention_label(issue.has_label(tracker_policy.needs_attention_label())) @@ -939,7 +915,7 @@ fn applied_goal_issue_rows( } else { GoalIntakeIssueAction::Created }, - evaluation.and_then(ExecutionNodeEvaluation::queue_label_action), + evaluation.and_then(ExecutionNodeEvaluation::dispatch_action), evaluation.map_or_else(Vec::new, |node| node.reasons().to_vec()), ) }) @@ -977,7 +953,7 @@ fn goal_issue_report_row( plan: &GoalIssuePlan, issue: Option<&TrackerIssue>, action: GoalIntakeIssueAction, - queue_label_action: Option, + dispatch_action: Option, reasons: Vec, ) -> GoalIntakeIssueReport { GoalIntakeIssueReport { @@ -988,7 +964,7 @@ fn goal_issue_report_row( issue_identifier: issue.map(|issue| issue.identifier.clone()), action, queue_intent: ExecutionQueueIntent::ReadyToQueue.as_str().to_owned(), - queue_label_action: queue_label_action.map(queue_label_action_name), + dispatch_action: dispatch_action.map(dispatch_action_name), dependencies: plan.dependencies.clone(), conflict_domains: conflict_domain_labels(&plan.conflict_domains), acceptance: plan.acceptance.clone(), @@ -1299,7 +1275,7 @@ fn unmapped_node(identifier: &str) -> Result { ExecutionProgramNode::new( format!("unmapped:{identifier}"), ExecutionProgramNodeStage::Runtime, - format!("Resolve supplied Linear issue identifier `{identifier}` before queueing."), + format!("Resolve supplied Linear issue identifier `{identifier}` before dispatch."), ExecutionQueueIntent::NotReady, )? .with_acceptance_expectations([format!( @@ -1310,23 +1286,14 @@ fn unmapped_node(identifier: &str) -> Result { fn issue_facts( tracker: &T, - state_store: &StateStore, - service_id: &str, workflow: &WorkflowDocument, issue: &TrackerIssue, - queue_label: &str, active_label: &str, ) -> Result where T: IssueTracker + ?Sized, { let tracker_policy = workflow.frontmatter().tracker(); - let has_queue_label = - tracker::issue_has_label_with_server_confirmation(tracker, issue, queue_label)?; - let queue_label_program_owned = has_queue_label - && !state_store - .program_queue_label_ownership_for_issue(service_id, &issue.id, queue_label)? - .is_empty(); let has_active_label = tracker::issue_has_label_with_server_confirmation(tracker, issue, active_label)?; let has_opt_out_label = tracker::issue_has_label_with_server_confirmation( @@ -1341,17 +1308,13 @@ where )?; let has_open_blockers = issue.blockers.iter().any(|blocker| !state_name_is_terminal(&blocker.state.name, workflow)); - let has_human_owned_queue_label = has_queue_label && !queue_label_program_owned; Ok(IssueFacts { - has_queue_label, - queue_label_program_owned, has_active_label, has_opt_out_label, has_needs_attention_label, has_generic_dispatch_briefing: issue_has_generic_dispatch_briefing(issue), has_open_blockers, - has_human_owned_queue_label, }) } @@ -1365,11 +1328,6 @@ fn issue_node( let mut mapping = ExecutionLinearIssueMapping::new(&issue.id, &issue.identifier, &issue.state.name)?; - mapping = if facts.queue_label_program_owned { - mapping.with_program_owned_queue_label(true) - } else { - mapping.with_queue_label(facts.has_queue_label) - }; mapping = mapping .with_active_label(facts.has_active_label) .with_opt_out_label(facts.has_opt_out_label) @@ -1407,7 +1365,7 @@ fn issue_queue_intent( if facts.has_active_label { return ExecutionQueueIntent::Active; } - if facts.has_opt_out_label || facts.has_human_owned_queue_label { + if facts.has_opt_out_label { return ExecutionQueueIntent::NotReady; } if !workflow @@ -1506,10 +1464,6 @@ fn issue_report_row( let classification = classify_issue(issue, facts, evaluation, workflow); let mut reasons = evaluation.reasons().to_vec(); - if facts.has_human_owned_queue_label { - reasons.push(String::from("service queue label is present without program-owned evidence")); - } - reasons.sort(); reasons.dedup(); @@ -1541,7 +1495,7 @@ fn issue_report_row( .as_str() .to_owned(), ), - queue_label_action: evaluation.queue_label_action().map(queue_label_action_name), + dispatch_action: evaluation.dispatch_action().map(dispatch_action_name), reasons, blockers, conflict_domains, @@ -1557,7 +1511,7 @@ fn classify_issue( if state_name_is_terminal(&issue.state.name, workflow) { return IssueBatchIntakeClassification::Stale; } - if facts.has_active_label || facts.has_opt_out_label || facts.has_human_owned_queue_label { + if facts.has_active_label || facts.has_opt_out_label { return IssueBatchIntakeClassification::Held; } if facts.has_needs_attention_label @@ -1581,11 +1535,9 @@ fn classify_issue( } } -fn queue_label_action_name(action: ExecutionQueueLabelAction) -> String { +fn dispatch_action_name(action: ExecutionDispatchAction) -> String { match action { - ExecutionQueueLabelAction::Apply => "apply", - ExecutionQueueLabelAction::Retain => "retain", - ExecutionQueueLabelAction::Remove => "remove", + ExecutionDispatchAction::Dispatch => "dispatch", } .to_owned() } @@ -1597,7 +1549,7 @@ fn unmapped_report_row(identifier: &str) -> IssueBatchIntakeIssueReport { issue_state: None, classification: IssueBatchIntakeClassification::Unmapped, queue_intent: None, - queue_label_action: None, + dispatch_action: None, reasons: vec![String::from("tracker issue identifier did not resolve")], blockers: Vec::new(), conflict_domains: Vec::new(), @@ -2094,8 +2046,8 @@ mod tests { assert_eq!(tracker.created_issue_count(), 1); assert_eq!(report.issues[0].action, GoalIntakeIssueAction::Updated); assert_eq!(report.issues[1].action, GoalIntakeIssueAction::Created); - assert_eq!(report.issues[0].queue_label_action.as_deref(), Some("apply")); - assert_eq!(report.issues[1].queue_label_action.as_deref(), Some("apply")); + assert_eq!(report.issues[0].dispatch_action.as_deref(), Some("dispatch")); + assert_eq!(report.issues[1].dispatch_action.as_deref(), Some("dispatch")); let linked_contract = store .decision_contract("decodex", "goal-intake-contract") diff --git a/apps/decodex/src/state/internal.rs b/apps/decodex/src/state/internal.rs index 2d2387c05..28f1fbcbc 100644 --- a/apps/decodex/src/state/internal.rs +++ b/apps/decodex/src/state/internal.rs @@ -16,8 +16,6 @@ use libc::{ use process::Command; use rusqlite::{self, Row, types::Type}; -use crate::tracker; - static RUN_ACTIVITY_MARKER_WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0); pub(crate) struct EffectiveRuntimeMarker<'a> { @@ -135,8 +133,6 @@ struct StateData { execution_programs: HashMap, program_intake_plans: HashMap, program_issue_mappings: HashMap, - program_queue_label_ownership: - HashMap, review_handoffs: HashMap, review_orchestrations: HashMap, review_policy_checkpoints: HashMap, @@ -162,7 +158,6 @@ impl StateData { self.execution_programs = loaded.execution_programs; self.program_intake_plans = loaded.program_intake_plans; self.program_issue_mappings = loaded.program_issue_mappings; - self.program_queue_label_ownership = loaded.program_queue_label_ownership; self.review_handoffs = loaded.review_handoffs; self.review_orchestrations = loaded.review_orchestrations; self.review_policy_checkpoints = loaded.review_policy_checkpoints; @@ -711,6 +706,8 @@ CREATE TABLE IF NOT EXISTS program_intake_plans ( ); CREATE INDEX IF NOT EXISTS program_intake_plans_project_idx ON program_intake_plans (project_id, intake_kind, updated_at_unix); +DROP TABLE IF EXISTS program_issue_mappings; +DROP TABLE IF EXISTS program_queue_label_ownership; CREATE TABLE IF NOT EXISTS program_issue_mappings ( project_id TEXT NOT NULL, program_id TEXT NOT NULL, @@ -719,8 +716,6 @@ CREATE TABLE IF NOT EXISTS program_issue_mappings ( issue_identifier TEXT NOT NULL, issue_state TEXT NOT NULL, queue_intent TEXT NOT NULL, - has_queue_label INTEGER NOT NULL, - queue_label_owned_by_program_reconciler INTEGER NOT NULL, has_active_label INTEGER NOT NULL, has_opt_out_label INTEGER NOT NULL, has_needs_attention_label INTEGER NOT NULL, @@ -733,22 +728,6 @@ CREATE TABLE IF NOT EXISTS program_issue_mappings ( ); CREATE INDEX IF NOT EXISTS program_issue_mappings_issue_idx ON program_issue_mappings (project_id, issue_id, updated_at_unix); -CREATE TABLE IF NOT EXISTS program_queue_label_ownership ( - project_id TEXT NOT NULL, - program_id TEXT NOT NULL, - node_id TEXT NOT NULL, - issue_id TEXT NOT NULL, - issue_identifier TEXT NOT NULL, - label_name TEXT NOT NULL, - service_id TEXT NOT NULL, - created_at TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - updated_at TEXT NOT NULL, - updated_at_unix INTEGER NOT NULL, - PRIMARY KEY (project_id, program_id, node_id, label_name) -); -CREATE INDEX IF NOT EXISTS program_queue_label_ownership_issue_idx -ON program_queue_label_ownership (project_id, issue_id, label_name, updated_at_unix); "#, )?; self.backfill_program_intake_state_from_execution_programs()?; @@ -902,10 +881,6 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; "DELETE FROM program_issue_mappings WHERE project_id = ?1", params![service_id], )?; - transaction.execute( - "DELETE FROM program_queue_label_ownership WHERE project_id = ?1", - params![service_id], - )?; transaction.execute( "DELETE FROM loop_guardrail_checkpoints WHERE project_id = ?1", params![service_id], @@ -1236,10 +1211,6 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; "DELETE FROM program_issue_mappings WHERE project_id = ?1 AND program_id = ?2", params![&record.project_id, record.program.program_id()], )?; - self.connection.execute( - "DELETE FROM program_queue_label_ownership WHERE project_id = ?1 AND program_id = ?2", - params![&record.project_id, record.program.program_id()], - )?; insert_program_intake_state(&self.connection, record) } @@ -1295,10 +1266,6 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; "UPDATE program_issue_mappings SET issue_id = ?2 WHERE issue_id = ?1", params![previous_issue_id, canonical_issue_id], )?; - transaction.execute( - "UPDATE program_queue_label_ownership SET issue_id = ?2 WHERE issue_id = ?1", - params![previous_issue_id, canonical_issue_id], - )?; transaction.execute( "INSERT OR IGNORE INTO loop_guardrail_checkpoints ( project_id, issue_id, reason, fingerprint, run_id, attempt_number, @@ -2302,17 +2269,6 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; record, ); } - for record in self.list_all_program_queue_label_ownership()? { - state.program_queue_label_ownership.insert( - ProgramQueueLabelOwnershipKey::new( - &record.project_id, - &record.program_id, - &record.node_id, - &record.label_name, - ), - record, - ); - } Ok(()) } @@ -2360,8 +2316,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; fn list_all_program_issue_mappings(&self) -> Result> { let mut statement = self.connection.prepare( "SELECT project_id, program_id, node_id, issue_id, issue_identifier, issue_state, \ - queue_intent, has_queue_label, queue_label_owned_by_program_reconciler, \ - has_active_label, has_opt_out_label, has_needs_attention_label, \ + queue_intent, has_active_label, has_opt_out_label, has_needs_attention_label, \ has_generic_dispatch_briefing, created_at, created_at_unix, updated_at, \ updated_at_unix \ FROM program_issue_mappings \ @@ -2384,8 +2339,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; ) -> Result> { let mut statement = self.connection.prepare( "SELECT project_id, program_id, node_id, issue_id, issue_identifier, issue_state, \ - queue_intent, has_queue_label, queue_label_owned_by_program_reconciler, \ - has_active_label, has_opt_out_label, has_needs_attention_label, \ + queue_intent, has_active_label, has_opt_out_label, has_needs_attention_label, \ has_generic_dispatch_briefing, created_at, created_at_unix, updated_at, \ updated_at_unix \ FROM program_issue_mappings \ @@ -2403,51 +2357,6 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(records) } - fn list_all_program_queue_label_ownership( - &self, - ) -> Result> { - let mut statement = self.connection.prepare( - "SELECT project_id, program_id, node_id, issue_id, issue_identifier, label_name, \ - service_id, created_at, created_at_unix, updated_at, updated_at_unix \ - FROM program_queue_label_ownership \ - ORDER BY project_id ASC, program_id ASC, node_id ASC, label_name ASC", - )?; - let rows = statement.query_map([], program_queue_label_ownership_row)?; - let mut records = Vec::new(); - - for row in rows { - records.push(row?); - } - - Ok(records) - } - - fn program_queue_label_ownership_for_issue( - &self, - project_id: &str, - issue_id: &str, - label_name: &str, - ) -> Result> { - let mut statement = self.connection.prepare( - "SELECT project_id, program_id, node_id, issue_id, issue_identifier, label_name, \ - service_id, created_at, created_at_unix, updated_at, updated_at_unix \ - FROM program_queue_label_ownership \ - WHERE project_id = ?1 AND issue_id = ?2 AND label_name = ?3 \ - ORDER BY updated_at_unix ASC, program_id ASC, node_id ASC", - )?; - let rows = statement.query_map( - params![project_id, issue_id, label_name], - program_queue_label_ownership_row, - )?; - let mut records = Vec::new(); - - for row in rows { - records.push(row?); - } - - Ok(records) - } - fn load_review_handoffs(&self, state: &mut StateData) -> Result<()> { let mut statement = self.connection.prepare( "SELECT project_id, issue_id, branch_name, run_id, attempt_number, pr_url, \ @@ -2963,24 +2872,6 @@ impl ProgramIssueMappingKey { } } -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -struct ProgramQueueLabelOwnershipKey { - project_id: String, - program_id: String, - node_id: String, - label_name: String, -} -impl ProgramQueueLabelOwnershipKey { - fn new(project_id: &str, program_id: &str, node_id: &str, label_name: &str) -> Self { - Self { - project_id: project_id.to_owned(), - program_id: program_id.to_owned(), - node_id: node_id.to_owned(), - label_name: label_name.to_owned(), - } - } -} - #[derive(Clone, Debug)] struct WorktreeMappingRecord { project_id: String, @@ -4206,11 +4097,10 @@ fn persist_program_intake_state(transaction: &Transaction<'_>, state: &StateData transaction.execute( "INSERT OR REPLACE INTO program_issue_mappings ( project_id, program_id, node_id, issue_id, issue_identifier, issue_state, - queue_intent, has_queue_label, queue_label_owned_by_program_reconciler, - has_active_label, has_opt_out_label, has_needs_attention_label, + queue_intent, has_active_label, has_opt_out_label, has_needs_attention_label, has_generic_dispatch_briefing, created_at, created_at_unix, updated_at, updated_at_unix - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", params![ &record.project_id, &record.program_id, @@ -4219,8 +4109,6 @@ fn persist_program_intake_state(transaction: &Transaction<'_>, state: &StateData &record.issue_identifier, &record.issue_state, &record.queue_intent, - sqlite_bool_value(record.has_queue_label), - sqlite_bool_value(record.queue_label_owned_by_program_reconciler), sqlite_bool_value(record.has_active_label), sqlite_bool_value(record.has_opt_out_label), sqlite_bool_value(record.has_needs_attention_label), @@ -4232,27 +4120,6 @@ fn persist_program_intake_state(transaction: &Transaction<'_>, state: &StateData ], )?; } - for record in state.program_queue_label_ownership.values() { - transaction.execute( - "INSERT OR REPLACE INTO program_queue_label_ownership ( - project_id, program_id, node_id, issue_id, issue_identifier, label_name, - service_id, created_at, created_at_unix, updated_at, updated_at_unix - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - params![ - &record.project_id, - &record.program_id, - &record.node_id, - &record.issue_id, - &record.issue_identifier, - &record.label_name, - &record.service_id, - &record.created_at, - record.created_at_unix, - &record.updated_at, - record.updated_at_unix, - ], - )?; - } Ok(()) } @@ -4287,11 +4154,10 @@ fn insert_program_intake_state( connection.execute( "INSERT OR REPLACE INTO program_issue_mappings ( project_id, program_id, node_id, issue_id, issue_identifier, issue_state, - queue_intent, has_queue_label, queue_label_owned_by_program_reconciler, - has_active_label, has_opt_out_label, has_needs_attention_label, + queue_intent, has_active_label, has_opt_out_label, has_needs_attention_label, has_generic_dispatch_briefing, created_at, created_at_unix, updated_at, updated_at_unix - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", params![ &mapping.project_id, &mapping.program_id, @@ -4300,8 +4166,6 @@ fn insert_program_intake_state( &mapping.issue_identifier, &mapping.issue_state, &mapping.queue_intent, - sqlite_bool_value(mapping.has_queue_label), - sqlite_bool_value(mapping.queue_label_owned_by_program_reconciler), sqlite_bool_value(mapping.has_active_label), sqlite_bool_value(mapping.has_opt_out_label), sqlite_bool_value(mapping.has_needs_attention_label), @@ -4313,27 +4177,6 @@ fn insert_program_intake_state( ], )?; } - for ownership in derived_program_queue_label_ownership_records(record) { - connection.execute( - "INSERT OR REPLACE INTO program_queue_label_ownership ( - project_id, program_id, node_id, issue_id, issue_identifier, label_name, - service_id, created_at, created_at_unix, updated_at, updated_at_unix - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - params![ - &ownership.project_id, - &ownership.program_id, - &ownership.node_id, - &ownership.issue_id, - &ownership.issue_identifier, - &ownership.label_name, - &ownership.service_id, - &ownership.created_at, - ownership.created_at_unix, - &ownership.updated_at, - ownership.updated_at_unix, - ], - )?; - } Ok(()) } @@ -5323,34 +5166,14 @@ fn program_issue_mapping_row( issue_identifier: row.get(4)?, issue_state: row.get(5)?, queue_intent: row.get(6)?, - has_queue_label: sqlite_bool(row, 7)?, - queue_label_owned_by_program_reconciler: sqlite_bool(row, 8)?, - has_active_label: sqlite_bool(row, 9)?, - has_opt_out_label: sqlite_bool(row, 10)?, - has_needs_attention_label: sqlite_bool(row, 11)?, - has_generic_dispatch_briefing: sqlite_bool(row, 12)?, - created_at: row.get(13)?, - created_at_unix: row.get(14)?, - updated_at: row.get(15)?, - updated_at_unix: row.get(16)?, - }) -} - -fn program_queue_label_ownership_row( - row: &Row<'_>, -) -> std::result::Result { - Ok(ProgramQueueLabelOwnershipRecord { - project_id: row.get(0)?, - program_id: row.get(1)?, - node_id: row.get(2)?, - issue_id: row.get(3)?, - issue_identifier: row.get(4)?, - label_name: row.get(5)?, - service_id: row.get(6)?, - created_at: row.get(7)?, - created_at_unix: row.get(8)?, - updated_at: row.get(9)?, - updated_at_unix: row.get(10)?, + has_active_label: sqlite_bool(row, 7)?, + has_opt_out_label: sqlite_bool(row, 8)?, + has_needs_attention_label: sqlite_bool(row, 9)?, + has_generic_dispatch_briefing: sqlite_bool(row, 10)?, + created_at: row.get(11)?, + created_at_unix: row.get(12)?, + updated_at: row.get(13)?, + updated_at_unix: row.get(14)?, }) } @@ -5433,17 +5256,6 @@ fn compare_program_issue_mapping_records( .then_with(|| left.node_id.cmp(&right.node_id)) } -fn compare_program_queue_label_ownership_records( - left: &ProgramQueueLabelOwnershipRecord, - right: &ProgramQueueLabelOwnershipRecord, -) -> cmp::Ordering { - left.updated_at_unix - .cmp(&right.updated_at_unix) - .then_with(|| left.program_id.cmp(&right.program_id)) - .then_with(|| left.node_id.cmp(&right.node_id)) - .then_with(|| left.label_name.cmp(&right.label_name)) -} - fn remove_derived_program_intake_state( state: &mut StateData, project_id: &str, @@ -5455,9 +5267,6 @@ fn remove_derived_program_intake_state( state .program_issue_mappings .retain(|key, _record| key.project_id != project_id || key.program_id != program_id); - state.program_queue_label_ownership.retain(|key, _record| { - key.project_id != project_id || key.program_id != program_id - }); } fn apply_derived_program_intake_state( @@ -5482,17 +5291,6 @@ fn apply_derived_program_intake_state( mapping, ); } - for ownership in derived_program_queue_label_ownership_records(record) { - state.program_queue_label_ownership.insert( - ProgramQueueLabelOwnershipKey::new( - &ownership.project_id, - &ownership.program_id, - &ownership.node_id, - &ownership.label_name, - ), - ownership, - ); - } } fn derived_program_intake_plan_records( @@ -5537,9 +5335,6 @@ fn derived_program_issue_mapping_records( issue_identifier: issue.issue_identifier().to_owned(), issue_state: issue.issue_state().to_owned(), queue_intent: node.queue_intent().as_str().to_owned(), - has_queue_label: issue.has_queue_label(), - queue_label_owned_by_program_reconciler: issue - .queue_label_owned_by_program_reconciler(), has_active_label: issue.has_active_label(), has_opt_out_label: issue.has_opt_out_label(), has_needs_attention_label: issue.has_needs_attention_label(), @@ -5553,39 +5348,6 @@ fn derived_program_issue_mapping_records( .collect() } -fn derived_program_queue_label_ownership_records( - record: &ExecutionProgramRuntimeRecord, -) -> Vec { - let label_name = tracker::automation_queue_label(record.program.service_id()); - - record - .program - .nodes() - .iter() - .filter_map(|node| { - let issue = node.linear_issue()?; - - if !issue.queue_label_owned_by_program_reconciler() { - return None; - } - - Some(ProgramQueueLabelOwnershipRecord { - project_id: record.project_id.clone(), - program_id: record.program.program_id().to_owned(), - node_id: node.node_id().to_owned(), - issue_id: issue.issue_id().to_owned(), - issue_identifier: issue.issue_identifier().to_owned(), - label_name: label_name.clone(), - service_id: record.program.service_id().to_owned(), - created_at: record.created_at.clone(), - created_at_unix: record.created_at_unix, - updated_at: record.updated_at.clone(), - updated_at_unix: record.updated_at_unix, - }) - }) - .collect() -} - fn compare_project_run_status(left: &ProjectRunStatus, right: &ProjectRunStatus) -> cmp::Ordering { right .active_lease diff --git a/apps/decodex/src/state/models.rs b/apps/decodex/src/state/models.rs index 2bbf16c17..ef7b0ecfd 100644 --- a/apps/decodex/src/state/models.rs +++ b/apps/decodex/src/state/models.rs @@ -718,8 +718,6 @@ pub(crate) struct ProgramIssueMappingRecord { issue_identifier: String, issue_state: String, queue_intent: String, - has_queue_label: bool, - queue_label_owned_by_program_reconciler: bool, has_active_label: bool, has_opt_out_label: bool, has_needs_attention_label: bool, @@ -759,14 +757,6 @@ impl ProgramIssueMappingRecord { &self.queue_intent } - pub(crate) fn has_queue_label(&self) -> bool { - self.has_queue_label - } - - pub(crate) fn queue_label_owned_by_program_reconciler(&self) -> bool { - self.queue_label_owned_by_program_reconciler - } - pub(crate) fn has_active_label(&self) -> bool { self.has_active_label } @@ -800,68 +790,6 @@ impl ProgramIssueMappingRecord { } } -/// SQLite-backed queue-label ownership proof for one program-owned label. -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ProgramQueueLabelOwnershipRecord { - project_id: String, - program_id: String, - node_id: String, - issue_id: String, - issue_identifier: String, - label_name: String, - service_id: String, - created_at: String, - created_at_unix: i64, - updated_at: String, - updated_at_unix: i64, -} -#[allow(dead_code)] -impl ProgramQueueLabelOwnershipRecord { - pub(crate) fn project_id(&self) -> &str { - &self.project_id - } - - pub(crate) fn program_id(&self) -> &str { - &self.program_id - } - - pub(crate) fn node_id(&self) -> &str { - &self.node_id - } - - pub(crate) fn issue_id(&self) -> &str { - &self.issue_id - } - - pub(crate) fn issue_identifier(&self) -> &str { - &self.issue_identifier - } - - pub(crate) fn label_name(&self) -> &str { - &self.label_name - } - - pub(crate) fn service_id(&self) -> &str { - &self.service_id - } - - pub(crate) fn created_at(&self) -> &str { - &self.created_at - } - - pub(crate) fn created_at_unix(&self) -> i64 { - self.created_at_unix - } - - pub(crate) fn updated_at(&self) -> &str { - &self.updated_at - } - - pub(crate) fn updated_at_unix(&self) -> i64 { - self.updated_at_unix - } -} - /// Latest runtime-owned review-policy checkpoint for one run phase. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ReviewPolicyCheckpoint { diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index 239a27ecc..651438cee 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -370,13 +370,6 @@ impl StateStore { { record.issue_id = canonical_issue_id.to_owned(); } - for record in state - .program_queue_label_ownership - .values_mut() - .filter(|record| record.issue_id == previous_issue_id) - { - record.issue_id = canonical_issue_id.to_owned(); - } self.retarget_issue_identity_locked(previous_issue_id, canonical_issue_id) } @@ -2164,45 +2157,6 @@ impl StateStore { Ok(records) } - /// Read program-owned queue-label evidence for one mapped issue and label. - #[allow(dead_code)] - pub(crate) fn program_queue_label_ownership_for_issue( - &self, - project_id: &str, - issue_id: &str, - label_name: &str, - ) -> Result> { - validate_required_execution_program_field("project_id", project_id)?; - validate_required_execution_program_field("issue_id", issue_id)?; - validate_required_execution_program_field("label_name", label_name)?; - - if let Some(sqlite) = &self.sqlite { - let sqlite = sqlite.lock().map_err(|_| eyre::eyre!("State store lock poisoned."))?; - - return sqlite.program_queue_label_ownership_for_issue( - project_id, - issue_id, - label_name, - ); - } - - let state = self.lock()?; - let mut records = state - .program_queue_label_ownership - .values() - .filter(|record| { - record.project_id == project_id - && record.issue_id == issue_id - && record.label_name == label_name - }) - .cloned() - .collect::>(); - - records.sort_by(compare_program_queue_label_ownership_records); - - Ok(records) - } - /// Count protocol journal records for one run. pub fn event_count(&self, run_id: &str) -> Result { let state = self.lock()?; diff --git a/apps/decodex/src/state/tests.rs b/apps/decodex/src/state/tests.rs index 18ca28590..415117e17 100644 --- a/apps/decodex/src/state/tests.rs +++ b/apps/decodex/src/state/tests.rs @@ -107,8 +107,7 @@ fn sample_execution_program(contract: &DecisionContract) -> ExecutionProgram { .expect("validation expectations should attach") .with_linear_issue( ExecutionLinearIssueMapping::new("issue-853", "XY-853", "Todo") - .expect("issue mapping should validate") - .with_program_owned_queue_label(true), + .expect("issue mapping should validate"), ) .expect("issue mapping should attach"); @@ -3212,15 +3211,8 @@ fn execution_programs_persist_reload_and_list_by_contract() { assert_eq!(issue_mappings.len(), 1); assert_eq!(issue_mappings[0].node_id(), "runtime-readiness"); assert_eq!(issue_mappings[0].issue_identifier(), "XY-853"); - assert!(issue_mappings[0].queue_label_owned_by_program_reconciler()); - - let queue_ownership = reopened - .program_queue_label_ownership_for_issue("decodex", "issue-853", "decodex:queued:decodex") - .expect("program queue label ownership should read"); - - assert_eq!(queue_ownership.len(), 1); - assert_eq!(queue_ownership[0].program_id(), "program-853"); - assert_eq!(queue_ownership[0].issue_identifier(), "XY-853"); + assert_eq!(issue_mappings[0].queue_intent(), "ready_to_queue"); + assert!(!issue_mappings[0].has_active_label()); } #[test] diff --git a/docs/decisions/decodex-plugin-source.md b/docs/decisions/decodex-plugin-source.md index 1fc0b208b..eab5480a8 100644 --- a/docs/decisions/decodex-plugin-source.md +++ b/docs/decisions/decodex-plugin-source.md @@ -33,8 +33,8 @@ instructions. The plugin should own reusable agent-facing procedures and mode routing: - `decodex` for choosing manual CLI mode versus automation mode -- `planning` for Decodex-friendly issue splitting, queue shaping, dependencies, and - concurrency +- `planning` for Decodex-friendly issue splitting, dispatch readiness, dependencies, + and concurrency - `manual-cli` for operator CLI use - `automation` for retained-lane control-plane use - `commit` for human-driven `decodex commit` diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 80f0bcfe3..214ce21c2 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -37,21 +37,20 @@ Decodex currently runs as a local, single-machine control plane: [`../spec/loop-runtime.md`](../spec/loop-runtime.md), not by dashboard graph editing or user-visible DAG commands. - Program intake readback is intentionally summarized: operators may see counts of - ready, queued, blocked, held, stale, attention, completed, and - queue-label-eligible nodes plus mapped issue identifiers. Optional planned, mapped, - active, and superseded counts may appear when the runtime has that detail. Low-level - node edges, graph operations, and queue-label ownership records remain internal - runtime state. Status JSON may include sparse node readbacks for queue-label - decisions and held, blocked, stale, active, or attention-bound nodes: mapped issue - identifier, issue state, lifecycle/readiness state, queue-label action, public-safe - reason codes, public-safe reasons, and a recovery next action. It must not expose + ready, dispatchable, blocked, held, stale, attention, completed, and mapped issue + identifiers. Optional planned, mapped, active, and superseded counts may appear when + the runtime has that detail. Low-level node edges and graph operations remain + internal runtime state. Status JSON may include sparse node readbacks for direct + dispatch decisions and held, blocked, stale, active, or attention-bound nodes: + mapped issue identifier, issue state, lifecycle/readiness state, dispatch action, + public-safe reason codes, public-safe reasons, and a recovery next action. It must not expose Decision Contract payloads, raw graph edges, local paths, credentials, raw logs, or private runtime events. -- Persisted Execution Programs are reconciled during normal Linear scan ticks before - issue selection. Ready, startable, program-owned mapped nodes can receive the - service queue label automatically; blocked, stale, paused, terminal, - attention-required, active, or conflict-held nodes stay unqueued. Human-owned or - ownership-unknown queue labels are preserved. +- Persisted Execution Programs are evaluated by the Program scheduler before ordinary + queued-label issue selection. Ready, startable mapped nodes can be dispatched + directly with `program` dispatch mode; blocked, stale, paused, terminal, + attention-required, active, or conflict-held nodes stay held. Service queue labels + are not applied, removed, retained, or treated as Program ownership evidence. Decodex App is a native shell over the same local runtime and account-pool state. On launch it connects to an existing default local listener when one is reachable; if @@ -96,7 +95,7 @@ decodex research promote `research compile` writes a local Decision Contract candidate into runtime SQLite and returns a bounded outcome: `decision_ready`, `not_decision_ready`, `blocked`, or `needs_human_decision`. It may retain private evidence references, option comparisons, -proposed issue summaries, conflict domains, and queue intent for later issue shaping, +proposed issue summaries, conflict domains, and dispatch intent for later issue shaping, but it does not enqueue issues, mutate Linear state, set goals, or start lane execution. `research promote` records the accepted boundary for an existing contract; later issue generation or Execution Program readiness must consume the promoted @@ -141,8 +140,8 @@ Linear issues labeled with its matching `decodex:queued:` label. For example, a Decodex-only run intakes issues labeled `decodex:queued:decodex`; `rsnap` can stay enabled in the full project registry, and issues labeled `decodex:queued:rsnap` remain rsnap intake rather than Decodex intake. Operators may still enqueue normal -issues manually, but program-owned queue labels are applied and removed by the runtime -reconciler when persisted Execution Program readiness changes. +issues manually with service-scoped queue labels, but persisted Execution Programs +dispatch ready mapped nodes directly and do not mutate those labels. When `decodex run --dry-run` or the status output has no eligible intake candidate, the operator hint points to the short checklist: `Todo`, the service-scoped @@ -201,7 +200,7 @@ pins all new runs to an exhausted fixed account. | Surface | Owns | Does Not Own | | --- | --- | --- | -| Runtime SQLite DB | active leases, attempts, run-control channels, protocol events, private execution events, Decision Contracts, Program Intake Plans, internal Execution Programs, program-owned queue-label evidence, worktree mappings, retry state, retained PR state, review-policy checkpoints with structured independent-review detail, loop-guardrail checkpoints, phase-goal signals, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | +| Runtime SQLite DB | active leases, attempts, run-control channels, protocol events, private execution events, Decision Contracts, Program Intake Plans, internal Execution Programs, dispatch readiness, worktree mappings, retry state, retained PR state, review-policy checkpoints with structured independent-review detail, loop-guardrail checkpoints, phase-goal signals, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | | Central project config | `service_id`, repo root, worktree root, tracker/GitHub credential env-var names, enabled project registration | per-run state or issue ownership | | Project `WORKFLOW.md` | repo policy, validation gate, state names, retry/review policy | runtime ownership, queue labels, credentials, model overrides | | Linear | team-visible issue state, queue/active/manual-attention labels, coarse execution ledger comments, progress/failure/handoff/closeout summaries | high-frequency runtime truth, heartbeat, token pressure, raw attempts, private execution evidence, connector retry budgets | @@ -264,8 +263,8 @@ observers. Operator JSON snapshots include `execution_programs[]` for Program Intake and Execution Program readback. Each program row carries public intake kind/summary, source contract id when present, the compact summary counts, mapped issue -identifiers, queue-label action counts (`apply`, `retain`, `remove`), and sparse -`node_readbacks[]` for queue decisions or nodes that need operator context. Dependency +identifiers, dispatchable count, and sparse `node_readbacks[]` for direct dispatch +decisions or nodes that need operator context. Dependency diagnostics use `dependency_not_terminal` with a next action to complete the dependency issue or refresh the Execution Program dependency plan when a stale dependency program is the real blocker. @@ -311,7 +310,7 @@ protocol activity durable outside the local operator surface. | `Accounts` | Shared Codex account pool and usage table from `~/.codex/decodex/accounts.jsonl` when `[codex.accounts]` is enabled for a project. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. The row weight column shows the capacity multiplier used for pool usage estimates: `pro` accounts count as `20x`, and all other plans count as `1x`. Usage probes read Codex `/wham/usage` for window capacity and `/wham/profiles/me` for profile token stats such as lifetime tokens, peak daily tokens, longest task, streaks, and daily token activity. Selecting an account writes the global `[codex.accounts].fixed_account` selector in `~/.codex/decodex/config.toml`; clearing it returns all new account-pool runs to balanced account selection. Account display-name rerolls write `[codex.account_names.offsets]` in the same global config so Decodex App and the dashboard share the privacy-preserving names. Theme, sort, and identity-visibility preferences are client-local presentation state. The selector is global and does not pin a project to an account. | | `Projects` | Fleet-level project table. The section-level filter toggles between active project work and the full registry. Location is its own compact path column and can be obscured from the location header eye. `Activity` shows a relative timestamp or `-`; `Work` is `running/waiting/attention`. It should not duplicate per-lane details already shown below. | | `Running Lanes` | Active leased or live-executing issue lanes. A lane here is currently owned by this local control plane, or a live process/thread/protocol marker still explains active execution even when the queue lease is not held. It shows issue identity, phase, operation, attempt, queue lease state, execution liveness, thread/protocol status, child-agent activity when captured, phase-goal status when app-server reports it, timing, branch, and worktree. | -| `Program Intake` | Read-only Program Intake and Execution Program progress. It shows each active program's public intake summary, status, mapped issue identifiers, summary counts, queue-label action counts, and sparse node diagnostics for queue decisions or held/blocked/stale/attention nodes. It does not expose graph editing, raw node-edge mutation controls, Decision Contract payloads, or private runtime evidence. | +| `Program Intake` | Read-only Program Intake and Execution Program progress. It shows each active program's public intake summary, status, mapped issue identifiers, summary counts, dispatchable counts, and sparse node diagnostics for dispatch decisions or held/blocked/stale/attention nodes. It does not expose graph editing, raw node-edge mutation controls, Decision Contract payloads, or private runtime evidence. | | `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. Repeated identical open dependency blockers surface as `dependency_program_stale` after the guardrail threshold so operators can distinguish a stale Execution Program/dependency plan from a newly blocked queue item. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after stalled reconciliation, failure writeback, or retries, the candidate is partial retained progress and not just a generic stalled or retry-budget hold. Human-required authority stops expose their compact decision request fields here: `phase = human_required`, reason, boundary, `decision_request_id`, and `next_action`. When queued attention still maps to a run/attempt, it also carries the same compact loop status used by running lanes. Running lanes are not repeated as normal intake work. | | `Review & Landing` | Retained PR lanes after review handoff. This section owns post-review repair, wait-for-review, ready-to-land, closeout, cleanup, and blocked retained-lane visibility. Retained lanes expose compact loop status for their bound handoff run/attempt so operators can see review repair checkpoint state, architecture recovery stops, and boundary/human-required disposition without direct SQLite inspection. | | `Recovery Worktrees` | Retained local worktrees that are not currently owned by `Running Lanes`, `Review & Landing`, or queued attention in `Intake Queue`. This is the cleanup or recovery inbox for recovered paths, retained PR leftovers, and cleanup-only local worktrees. Empty is the normal healthy state. | diff --git a/docs/reference/test-suite.md b/docs/reference/test-suite.md index eab5d441c..4e8d70e04 100644 --- a/docs/reference/test-suite.md +++ b/docs/reference/test-suite.md @@ -71,7 +71,7 @@ large catch-all test file unless the behavior crosses several of these stages. | `apps/decodex/src/orchestrator/tests/retry/selection.rs` | 14 | Retry queue selection and blocked retry candidates | | `apps/decodex/src/orchestrator/tests/runtime/repo_gate.rs` | 9 | Repo gate command selection, cleanliness, shell fallback, and failure classification | | `apps/decodex/src/orchestrator/tests/runtime/failure.rs` | 38 | Failure comments, runtime credentials, cleanup, lease release | -| `apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs` | 2 | Research-to-execution loop scenarios, queue shaping, phase-goal validation, review, guardrails, and harness feedback | +| `apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs` | 2 | Research-to-execution loop scenarios, Program dispatch readiness, phase-goal validation, review, guardrails, and harness feedback | | `apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs` | 1 | Completed-thread archive candidate filtering | | `apps/decodex/src/orchestrator/tests/recovery/reconciliation.rs` | 22 | Stale lease, recovery worktree, and reconciliation behavior | | `apps/decodex/src/orchestrator/tests/recovery/terminal_support.rs` | 0 | Shared retained recovery and closeout fixtures | diff --git a/docs/runbook/index.md b/docs/runbook/index.md index 43a08d725..91282135e 100644 --- a/docs/runbook/index.md +++ b/docs/runbook/index.md @@ -51,6 +51,6 @@ Question this index answers: "which sequence should I execute?" release-candidate gate, dogfood evidence checklist, tag contract, and release note. - [`research-to-execution-loop.md`](./research-to-execution-loop.md) for compiling latent research contracts, promoting accepted results, inspecting Execution Program - queue shaping, and following validation, review, guardrail, and harness feedback. + dispatch readiness, and following validation, review, guardrail, and harness feedback. - [`self-dogfood-pilot.md`](./self-dogfood-pilot.md) for the retained-lane pilot run against `decodex` itself and the bounded live-operation sequence. diff --git a/docs/runbook/research-to-execution-loop.md b/docs/runbook/research-to-execution-loop.md index f5163f6fd..44a96b5c8 100644 --- a/docs/runbook/research-to-execution-loop.md +++ b/docs/runbook/research-to-execution-loop.md @@ -4,12 +4,12 @@ Purpose: Operate and inspect the natural-language research-to-execution loop wit turning latent research into automatic execution. Read this when: A user asks Decodex to research something, promotes the result, or -needs to inspect why only part of the resulting work queued. +needs to inspect why only part of the resulting work is dispatchable. Not this document: The normative schema or runtime invariants. Use [`../spec/loop-runtime.md`](../spec/loop-runtime.md) for the authority contract. -Covers: Latent research contracts, promotion, Execution Program queue shaping, +Covers: Latent research contracts, promotion, Execution Program dispatch readiness, phase-goal validation, independent review, guardrails, and harness improvement evidence. @@ -39,8 +39,8 @@ evidence. ``` Promotion records the accepted Decision Contract. It authorizes the runtime to - shape an internal Execution Program, but it is still not a request to queue every - possible node. + shape an internal Execution Program, but it is still not a request to dispatch + every possible node. 3. Inspect Execution Program readiness. @@ -50,8 +50,9 @@ evidence. Check the execution-program summary for ready, blocked, paused, active, and completed counts. Only ready nodes mapped to startable issues, without active, - opt-out, needs-attention, terminal-state, dependency, or conflict blockers may - receive or retain `decodex:queued:decodex`. + opt-out, needs-attention, terminal-state, dependency, or conflict blockers become + directly dispatchable by the Program scheduler. Program readiness must not apply, + retain, remove, or depend on `decodex:queued:decodex`. 4. Treat phase-goal completion as a validation boundary. @@ -92,7 +93,7 @@ flow when the lane evidence shows all of the following: - research output stays latent until promotion; - promotion creates an internal source of truth for execution shape; -- only ready nodes get the service queue label; +- only ready mapped nodes become directly dispatchable by the Program scheduler; - child-goal completion triggers validation and review instead of terminal success; - repair churn has bounded guardrails; - harness telemetry produces at least one concrete fixture, prompt, or contract diff --git a/docs/spec/loop-runtime.md b/docs/spec/loop-runtime.md index 3d6d1816c..0b888be80 100644 --- a/docs/spec/loop-runtime.md +++ b/docs/spec/loop-runtime.md @@ -49,7 +49,7 @@ dependencies, set goals, mutate tracker state, or start implementation. After acceptance or promotion, the accepted Loop/Decision Contract, shortened to Decision Contract in this spec, becomes loop-runtime authority. The runtime may then -shape or update normal Linear issues and queue intent, but executable work still runs +shape or update normal Linear issues and dispatch intent, but executable work still runs through the lane runtime contract. ## Research/Decision Stage @@ -71,7 +71,7 @@ A Research/Decision stage may produce a latent Loop/Decision Contract with: - dependency and blocker model - conflict domains such as `docs`, `runtime`, `site`, `tests`, or a more specific repository-owned domain -- proposed issue split and queue intent +- proposed issue split and dispatch intent - risk notes that decide whether independent review is required The latent contract is a candidate decision package. It becomes authoritative only @@ -106,7 +106,7 @@ The payload carries these top-level fields: | `research_evidence` | Non-authoritative evidence claims retained for later review and issue shaping. | | `research_options` | Non-authoritative option comparisons retained with tradeoffs, selected decision notes, or rejected-option reasons. | | `accepted_authority` | Objectives, non-goals, constraints, assumptions, objections, and stop conditions that become authority only when status is `accepted_promoted`. | -| `execution_readiness` | Natural-language readiness summary, missing decisions, validation expectations, risk notes, proposed issue summaries, conflict domains, and queue intent. It must not expose graph ids or require the user to operate a DAG. Accepted contracts must be ready for issue shaping and must not carry unresolved missing decisions. | +| `execution_readiness` | Natural-language readiness summary, missing decisions, validation expectations, risk notes, proposed issue summaries, conflict domains, and dispatch intent. It must not expose graph ids or require the user to operate a DAG. Accepted contracts must be ready for issue shaping and must not carry unresolved missing decisions. | | `promotion` | Metadata recording who or what accepted the decision, the acceptance source, and the acceptance time. Required only for `accepted_promoted`. | | `links` | Generated Linear issue ids/identifiers or internal Execution Program node ids when those exist. | | `evidence_boundary` | Local private evidence references and sparse public projection references. | @@ -116,7 +116,7 @@ The status is the authority boundary: - `draft_latent` means the research/design result is stored but cannot enqueue, mutate tracker state, set goals, or authorize implementation. - `accepted_promoted` means the payload's `accepted_authority` fields may be used by - the loop runtime to shape queue intent, generated issues, or internal Execution + the loop runtime to shape dispatch intent, generated issues, or internal Execution Program nodes. The payload must include promotion metadata, set `execution_readiness.ready_for_issue_shaping = true`, and leave `execution_readiness.missing_decisions` empty. @@ -291,9 +291,10 @@ artifact contains unresolved direction, contradictory requirements, or missing acceptance criteria, it must request more decision authority instead of starting execution. -Promotion may create or update normal Linear issues, dependencies, labels, or queue -intent. It must not expose an ordinary user workflow that depends on graph ids, -manual DAG edge editing, or hidden Codex goal state. +Promotion may create or update normal Linear issues, dependencies, and queue intent. +It must not apply service queue labels for Program readiness or expose an ordinary +user workflow that depends on graph ids, manual DAG edge editing, or hidden Codex goal +state. ## Program Intake Plan @@ -307,9 +308,8 @@ The durable intake-planning payload is versioned as `decodex.program_intake_plan/1` with `record_version = 1`. The runtime stores it in runtime SQLite as part of, or directly adjacent to, the internal `decodex.execution_program/1` record. The adjacent runtime readback rows are -`program_intake_plans`, `program_issue_mappings`, and -`program_queue_label_ownership`; they are derived from the versioned program payload -and exist so operator status, reconciliation, and tests can query intake state without +`program_intake_plans` and `program_issue_mappings`; they are derived from the +versioned program payload and exist so operator status, scheduling, and tests can query intake state without copying private graph payloads into Linear. Linear issue descriptions, Linear comments, and operator summaries may expose only sparse public projections. @@ -318,12 +318,12 @@ The payload carries: | Field | Meaning | | --- | --- | | `plan_id` | Stable runtime id for this intake plan. | -| `service_id` | Registered service that owns program reconciliation for this plan. | +| `service_id` | Registered service that owns program scheduling for this plan. | | `intake_kind` | `goal_intake` for promoted natural-language goals, or `issue_batch_intake` for a supplied batch of executable issue briefs. | | `source_contract_id` | Accepted Decision Contract id for goal intake, when present. | | `accepted_contract_fingerprint` | Fingerprint used to detect drift from the accepted contract or batch boundary. | | `public_summary` | Public-safe objective/readiness summary suitable for status readback. | -| `node_projection` | Optional public-safe node summary or count metadata. The full internal nodes, dependencies, conflict domains, issue mapping, queue-label ownership metadata, and lifecycle evaluation inputs live in the paired `decodex.execution_program/1` payload. | +| `node_projection` | Optional public-safe node summary or count metadata. The full internal nodes, dependencies, conflict domains, issue mapping, dispatch readiness, and lifecycle evaluation inputs live in the paired `decodex.execution_program/1` payload. | Goal intake and issue-batch intake both materialize normal Linear issues. A goal intake starts from an accepted Decision Contract and shapes one or more issue briefs. @@ -337,13 +337,13 @@ The operator CLI surface for promoted goals is `decodex intake goal --project --dry-run`, or the same command with `--config `. Dry-run reads the promoted Decision Contract and existing generated-issue links, then prints the proposed normal issue briefs, -dependencies, conflict domains, and queue plan without mutating Linear and without +dependencies, conflict domains, and dispatch plan without mutating Linear and without persisting local runtime rows. `--apply` is the explicit mutation boundary: it creates or updates generated normal Linear issue descriptions, links generated issue ids and node ids back to the Decision Contract, and stores the paired Program Intake Plan and -Execution Program in runtime SQLite. Apply must not run implementation directly and -must not apply or remove `decodex:queued:`; later program reconciliation -owns queue-label changes. If the contract is latent, rejected, still needs a human +Execution Program in runtime SQLite. Apply must not run implementation inline and +must not apply or remove `decodex:queued:`; the persisted Program is then +eligible for direct scheduler dispatch. If the contract is latent, rejected, still needs a human decision, carries unresolved missing decisions, or lacks proposed issue summaries, goal intake stops before creating executable work. @@ -352,9 +352,10 @@ The operator CLI surface for existing issues is command with `--config `. Dry-run reads tracker state and prints a deterministic ready/held/blocked/stale/unmapped report without mutating Linear and without persisting local runtime rows. `--apply` is an explicit local-runtime write: -it stores the Program Intake Plan, Execution Program payload, issue mappings, and any -already-proven program-owned queue-label evidence, but it still must not apply or -remove `decodex:queued:`. `--persist` is a legacy alias for `--apply`. +it stores the Program Intake Plan, Execution Program payload, and issue mappings, but +it must not apply or remove `decodex:queued:`. Ready mapped nodes are +dispatched directly by the Program scheduler rather than converted into queued-label +work. ## Internal Execution Program @@ -369,7 +370,7 @@ SQLite and carries: | Field | Meaning | | --- | --- | | `program_id` | Stable runtime identifier for this internal program. | -| `service_id` | Registered Decodex service that owns program reconciliation. | +| `service_id` | Registered Decodex service that owns program scheduling. | | `source_contract_id` | Accepted Decision Contract that authorized the program, when the program came from goal intake. | | `accepted_contract_fingerprint` | Fingerprint of the accepted contract or batch authority used for drift detection. | | `program_intake_plan` | The embedded or linked `decodex.program_intake_plan/1` payload. | @@ -387,17 +388,18 @@ Each program node carries: - acceptance expectations and validation expectations - runtime-derived lifecycle state: `planned`, `mapped`, `ready`, `queued`, `active`, `blocked`, `needs_attention`, `completed`, `stale`, or `superseded` -- queue intent: `not_ready`, `ready_to_queue`, `queued`, `active`, `paused`, `done`, - or `canceled`; `paused` is legacy queue intent and renders as held lifecycle - readback, not as a user-facing graph state +- dispatch intent: `not_ready`, `ready_to_queue`, `queued`, `active`, `paused`, `done`, + or `canceled`; `paused` renders as held lifecycle readback, not as a user-facing + graph state - linked normal Linear issue identity and startability facts when the node becomes executable -- queue-label ownership metadata for labels applied by program reconciliation +- direct dispatch readiness derived from issue state, dependency state, conflict + domains, and human-stop labels - accepted authority fingerprint used to detect node-level drift Normal Linear issues remain the executable Decodex lanes. A program node may become eligible only by creating or updating a normal issue with enough natural-language -briefing for generic dispatch and by applying the configured queue policy. The +briefing for generic dispatch and by satisfying the configured workflow policy. The Execution Program does not replace Linear as the team-visible backlog or the runtime lane model. @@ -406,44 +408,40 @@ Lifecycle evaluation is runtime-owned. It classifies nodes as: | State | Meaning | | --- | --- | | `planned` | The node exists only inside the Program Intake Plan and has no normal Linear issue mapping yet. | -| `mapped` | The node has a normal Linear issue mapping but is intentionally held from queueing. | -| `ready` | Dependencies, conflicts, acceptance expectations, validation expectations, and issue mapping allow normal execution, but the queue label has not been applied or retained yet. | -| `queued` | The node is ready and its mapped issue currently carries a program-owned service queue label. | -| `active` | The mapped issue already has an active lane and must not retain program-owned queue labels. | +| `mapped` | The node has a normal Linear issue mapping but is intentionally held from dispatch. | +| `ready` | Dependencies, conflicts, acceptance expectations, validation expectations, and issue mapping allow direct Program dispatch. | +| `queued` | Reserved for retained ready-to-dispatch state; new Program scheduling does not use service queue labels for this state. | +| `active` | The mapped issue already has an active lane. | | `blocked` | A dependency, conflict domain, missing expectation, non-startable issue state, missing issue mapping, or missing briefing blocks execution. | | `needs_attention` | The mapped issue carries the configured human-attention label or equivalent human-required stop. | | `completed` | The node is done or canceled under the accepted program. | | `stale` | The node or program no longer matches the accepted authority fingerprint. | | `superseded` | A later accepted contract or batch authority replaced this node. | -Queue-label action is derived from lifecycle readiness, not from graph presence alone. -Only a `ready` node whose queue intent is `ready_to_queue` or `queued`, and whose -mapped Linear issue is in a registered startable state with no opt-out, -needs-attention, active-label, missing-briefing, or terminal-state blocker, may -receive or retain `decodex:queued:`. - -Queue-label ownership is fail-closed: - -- Apply the service queue label only when the node is `ready`, the mapped issue lacks - the label, and program reconciliation records ownership for that node, issue, label, - service id, and program id. -- Retain the label only while the node remains `ready` or `queued`, the mapped issue - remains startable, and the program-owned label record still matches the same node - and service label. A human-owned queued label may be left in place but must not be - converted into program-owned removal authority. -- Remove the label only when a matching program-owned label record proves Decodex - applied it through program reconciliation and the node is no longer queueable, for - example `blocked`, `active`, `needs_attention`, `completed`, `stale`, - `superseded`, unmapped, terminal, or missing a generic dispatch briefing. -- If the queue label is present but no matching program-owned ownership record exists, - treat it as human-owned or ownership-unknown. Do not remove it during program - reconciliation; surface a public-safe ownership warning or local operator reason - instead. +Direct dispatch eligibility is derived from lifecycle readiness, not from graph +presence alone. Only a `ready` node whose dispatch intent is `ready_to_queue` or +`queued`, and whose mapped Linear issue is in a registered startable state with no +opt-out, needs-attention, active-label, missing-briefing, open blocker, or +terminal-state blocker, may receive `dispatch_action = dispatch`. + +The Program scheduler is event-driven from local runtime state: + +- Persisting or refreshing an Execution Program keeps the DAG in SQLite/local runtime + memory; the scheduler evaluates the graph before ordinary queued-label issue + selection. +- The scheduler refreshes only mapped Linear issue facts required for readiness: + issue state, active/manual-only/needs-attention labels, open blockers, briefing + presence, dependency observations, and occupied conflict domains. +- When several nodes are dispatchable, normal issue candidate ordering chooses the + first lane, and project/account concurrency still gates actual execution. +- Service queue labels are not applied, retained, removed, or treated as Program + ownership evidence by this path. Existing `decodex:queued:` labels + remain ordinary tracker intake for non-Program issues. Operator readback must summarize program progress without exposing graph operations as workflow. Public-safe readback fields are: intake kind, public summary, ready count, -queued count, blocked count, held count, stale count, attention count, completed -count, optional planned/mapped/active/superseded counts, queue-label-eligible count, +dispatchable count, blocked count, held count, stale count, attention count, completed +count, optional planned/mapped/active/superseded counts, dispatch action, mapped issue identifiers, and coarse next action. It must not expose internal graph ids, edge lists, raw dependency payloads, host-local paths, transcripts, credentials, or private evidence references as public/team-visible status. diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 1e4e7105f..bf702c17d 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -21,7 +21,7 @@ Contracts, internal Execution Programs, phase-scoped goals, unattended execution behavior, and loop guardrails. This document owns the lower-level lane runtime. A promoted Execution Program may -shape queue intent and normal Linear issues, but executable work still enters this +shape dispatch intent and normal Linear issues, but executable work still enters this runtime as ordinary issue lanes with leases, attempts, validation, review handoff, and tracker writeback. The internal program graph is not a replacement for Linear workflow state or this state machine. @@ -65,27 +65,25 @@ state or this state machine. not by themselves create Linear issues, queue intent, goals, or executable lanes. - `decodex intake goal --dry-run` is a tracker-read-only and runtime-read-only operator surface for promoted Decision Contracts. It renders the - proposed normal Linear issue split, dependencies, conflict domains, and queue plan + proposed normal Linear issue split, dependencies, conflict domains, and dispatch plan without mutating Linear or persisting Program Intake rows. `--apply` creates or updates generated normal Linear issue briefs, links generated issue ids and internal node ids back into the Decision Contract and Execution Program records, and persists the local Program Intake Plan plus Execution Program state. It must not start - implementation directly and must not apply queue labels. + implementation inline and must not apply queue labels; direct Program dispatch is + performed by the scheduler after the Program is persisted. - `decodex intake issues ... --dry-run` is a tracker-read-only operator surface for existing Linear issues. It classifies the supplied batch as ready, held, blocked, stale, or unmapped and builds the same internal program model used by later persistence, but it must not mutate Linear or write local runtime rows. - `--apply` writes only local runtime Program Intake and Execution Program state; - queue labels remain untouched until a later reconciliation surface is explicitly - authorized. `--persist` is a legacy alias for `--apply`. -- On each normal Linear scan tick, the control plane reconciles persisted Execution - Programs before normal issue selection. The reconciler refreshes mapped Linear - issue state, dependency observations, local active leases, retained review/landing - worktrees, needs-attention labels, and occupied conflict domains; then it applies - or removes only the service queue label decisions authorized by the Execution - Program readiness evaluator. It must not dispatch app-server runs directly. Any - newly queued issue still enters execution through the ordinary queue scan, - dispatch policy, lease acquisition, and scheduler path. + `--apply` writes only local runtime Program Intake and Execution Program state. +- Each scheduler pass evaluates persisted Execution Programs before ordinary queued + issue selection. The Program scheduler refreshes mapped Linear issue state, + dependency observations, local active leases, retained review/landing worktrees, + needs-attention labels, and occupied conflict domains; then it directly selects + dispatchable ready nodes with `program` dispatch mode. It must not apply or remove + service queue labels, and Program readiness must not wait for the ordinary + Linear-backed queue scan interval. The evidence boundary is ordered from private runtime authority to public collaboration mirror: @@ -94,10 +92,9 @@ mirror: | --- | --- | | Runtime SQLite `private_execution_events` | Structured private execution evidence for the local Decodex installation. This is where full checkpoint payloads, verification notes, local head evidence, recovery detail, and `decodex.harness_outcome/1` feedback records belong. | | Runtime SQLite `decision_contracts` | Versioned `decodex.decision_contract/1` payloads produced by research/design and later promoted into execution authority. The row status is indexed for local runtime lookup, but the JSON payload remains the contract authority. | -| Runtime SQLite `execution_programs` | Versioned `decodex.execution_program/1` payloads with embedded or linked `decodex.program_intake_plan/1` planning data. They hold internal node lifecycle/readiness, dependency, conflict-domain, queue-intent, drift, normal-issue mapping, and program-owned queue-label evidence; Linear issue descriptions and ledger comments are only coarse projections. | +| Runtime SQLite `execution_programs` | Versioned `decodex.execution_program/1` payloads with embedded or linked `decodex.program_intake_plan/1` planning data. They hold internal node lifecycle/readiness, dependency, conflict-domain, dispatch intent, drift, and normal-issue mapping; Linear issue descriptions and ledger comments are only coarse projections. | | Runtime SQLite `program_intake_plans` | Queryable local projection of `decodex.program_intake_plan/1` metadata, including intake kind, source contract when present, authority fingerprint, and public-safe summary. | -| Runtime SQLite `program_issue_mappings` | Queryable local projection of each internal program node's mapped Linear issue, tracker state, queue intent, service label facts, dispatch-briefing fact, and program-owned queue-label flag. | -| Runtime SQLite `program_queue_label_ownership` | Fail-closed evidence that program reconciliation owns a specific service queue label for one service, program, node, and issue. Queue-label removal authority must come from this table or the equivalent canonical program payload evidence. | +| Runtime SQLite `program_issue_mappings` | Queryable local projection of each internal program node's mapped Linear issue, tracker state, dispatch intent, active/manual/attention facts, and dispatch-briefing fact. | | Runtime SQLite `run_control_channels` | Local control capability metadata for active run attempts. It records the project, issue, run id, attempt, transport, local channel path, channel status, and publish/update timestamps needed to route future control requests without bypassing active lease ownership. | | Runtime SQLite `review_policy_checkpoints` | Latest bounded-review checkpoint state for one project, issue, run, attempt, and phase, including structured independent-review detail. This row is the authority for review handoff and retained repair gating. | | Runtime SQLite `loop_guardrail_checkpoints` | Latest convergence checkpoint for one project, issue, and guardrail reason. It stores the fingerprint, consecutive count, run id, attempt number, and structured detail used to stop non-converging loops without replaying Linear comments. | @@ -659,8 +656,8 @@ The runtime database stores at least: - Decision Contracts scoped by project and contract id, with optional source issue linkage for later issue shaping - Program Intake Plans and internal Execution Programs scoped by project, with - lifecycle/readiness state, normal Linear issue mappings, and program-owned - queue-label ownership evidence + lifecycle/readiness state, normal Linear issue mappings, and dispatchable-node + readback - worktree mappings - retained PR and post-review state - review-policy checkpoints @@ -776,14 +773,13 @@ After a process restart, recent-run history, active lease ownership, retained po must not inherit an old retained marker's exhausted retry budget unless a caller supplied an explicit preferred retry-budget base for that new run. - While the control plane owns a queued retry entry, that queued claim must take priority over normal candidate selection for the affected project. -- While the control plane evaluates persisted Execution Programs, program-owned ready - nodes may receive `decodex:queued:` only when their mapped Linear issue - is startable, non-terminal, briefed for generic dispatch, free of opt-out and - needs-attention labels, not already active, and not blocked by dependency or - occupied conflict-domain evidence. Blocked, stale, paused, terminal, active, or - attention-required nodes must not receive the label, and the reconciler may remove - the service queue label only when matching local ownership evidence identifies the - same service, program, node, issue, and label. +- While the control plane evaluates persisted Execution Programs, ready nodes may be + selected for `program` dispatch only when their mapped Linear issue is startable, + non-terminal, briefed for generic dispatch, free of opt-out and needs-attention + labels, not already active, and not blocked by dependency or occupied + conflict-domain evidence. Blocked, stale, paused, terminal, active, or + attention-required nodes stay held, and this path must not mutate service queue + labels. - While the control plane is idle between lanes, it may reload the configured project `WORKFLOW.md` on each tick and immediately apply a newly valid document to future dispatch, retry, post-exit reconciliation, and prompt generation. - If that same configured `WORKFLOW.md` path becomes invalid after a successful load, the control plane must log the reload failure and keep the last known good document active instead of dropping the tick or clearing runtime policy. - If the leased issue becomes terminal during a control-plane tick, `decodex` must stop the active run, mark the attempt `terminated`, clear the lease, and then retain or clean the worktree according to the retention rules above. diff --git a/docs/spec/tracker-tools.md b/docs/spec/tracker-tools.md index 4d5c77269..607e4df82 100644 --- a/docs/spec/tracker-tools.md +++ b/docs/spec/tracker-tools.md @@ -121,24 +121,17 @@ In either invalid case, `decodex` must fail the attempt rather than infer which public projections or generated issue links after promotion, but they must not copy the private `decodex.decision_contract/1` payload into Linear. - Internal Execution Programs are local runtime records, not tracker comments or - issue-description payloads. Queue-label mutations for generated issues must follow - the Execution Program readiness evaluator: only ready nodes mapped to normal - startable Linear issues may receive or retain `decodex:queued:`. - Program reconciliation runs as part of the normal control-plane scan path and must - use current tracker issue state, dependency observations, active leases, - review/landing ownership, attention labels, and occupied conflict domains before - mutating labels. It must record when it applied that label for a specific service, - program, node, issue, and label. It may remove only labels with matching - program-owned evidence. If the same queue label is present without matching - ownership evidence, the label is human-owned or ownership-unknown and must be left - in place. A reconciled label only feeds the existing scheduler queue scan; it must - not directly start an app-server run. + issue-description payloads. Program dispatch is direct: the scheduler evaluates + ready nodes mapped to normal startable Linear issues, refreshes only the tracker + facts required for readiness, and dispatches with `program` dispatch mode. Tracker + label mutation tools must not apply, retain, or remove + `decodex:queued:` for Program readiness. Service queue labels remain + the ordinary intake signal for non-Program issue lanes. - `decodex intake issues ... --dry-run` may read tracker state for supplied issues and render a local ready/held/blocked/stale/unmapped report, but it must not call tracker label mutation tools or write Linear comments. `--apply` may write local runtime Program Intake records and issue mappings, but it still must not apply or - remove `decodex:queued:`. `--persist` remains a legacy alias for - `--apply`. + remove `decodex:queued:`. - `issue_progress_checkpoint` must accept only the normalized execution phases `probing`, `implementing`, `verifying`, `blocked`, `ready_for_review`, `review_repair`, `ready_to_land`, and `closeout`. - `issue_progress_checkpoint` must not replace `issue_review_checkpoint`, `issue_review_handoff`, `issue_review_repair_complete`, `issue_closeout_complete`, or `issue_terminal_finalize`. - `decodex` treats `issue_progress_checkpoint` as execution memory only. Checkpoint phase, focus, next action, blockers, or evidence do not by themselves authorize review handoff, repair completion, merge, closeout, or terminal success. diff --git a/plugins/decodex/skills/automation/SKILL.md b/plugins/decodex/skills/automation/SKILL.md index 3346bc702..f7b9089ae 100644 --- a/plugins/decodex/skills/automation/SKILL.md +++ b/plugins/decodex/skills/automation/SKILL.md @@ -11,7 +11,7 @@ Operate Decodex as the retained-lane control plane for automatic development. Automation starts only after execution authority exists. Natural-language `research X` may create a latent Decision Contract, but latent research must not dispatch retained -lanes, set Codex goals, mutate tracker state, or apply queue labels until a later +lanes, set Codex goals, mutate tracker state, or create Program/queue intake until a later promotion request clearly accepts the contract. ## Read First @@ -32,12 +32,14 @@ promotion request clearly accepts the contract. request, or normal issue brief that grants implementation authority. 2. Inspect current state with `decodex status`, `decodex status --json`, or `decodex lane inspect ` before mutating anything. -3. Queue only ready issues with `decodex:queued:`. Use the `labels` skill - for Decodex Linear labels. - blocked, stale, paused, active, terminal, or unmapped internal nodes remain - unqueued. -4. Request `POST /api/linear-scan` after creating or relabeling queued work when the - scheduler should observe it before the next 5-minute poll. +3. For accepted Program work, use Program Intake (`decodex intake goal --apply` or + `decodex intake issues --apply`) and let the scheduler directly dispatch ready + mapped nodes. Blocked, stale, paused, active, terminal, or unmapped internal nodes + remain held. +4. For ordinary non-Program issues, queue only ready issues with + `decodex:queued:` and use the `labels` skill for Decodex Linear labels. + Request `POST /api/linear-scan` only when ordinary tracker label changes should be + observed before the next poll. 5. Let retained lanes finish through runtime-owned review, repair, handoff, landing, closeout, and cleanup unless the operator explicitly moves the lane to a manual path. @@ -67,8 +69,11 @@ cargo run -p decodex --bin decodex -- serve ## Required Signals -- Intake starts from `decodex:queued:` and active ownership uses - `decodex:active:`. +- Ordinary issue intake starts from `decodex:queued:` and active ownership + uses `decodex:active:`. +- Program Intake starts from persisted Execution Programs. Ready mapped nodes dispatch + directly with `program` dispatch mode; the scheduler does not apply, retain, or + remove service queue labels for Program readiness. - `decodex:manual-only` opts out of automation. - `decodex:needs-attention` is a human-required stop that automation must not silently retry. The only runtime-owned clear path is the explicit review-handoff rebind @@ -94,7 +99,7 @@ cargo run -p decodex --bin decodex -- serve ## Boundaries -- Do not expose graph ids, DAG edge editing, hidden goal ids, or queue-label mechanics +- Do not expose graph ids, DAG edge editing, hidden goal ids, or Program dispatch mechanics as the ordinary user workflow. - Do not directly edit runtime DB rows, kill hidden `_attempt` children, or mutate Linear state to simulate lane controls. diff --git a/plugins/decodex/skills/decodex/SKILL.md b/plugins/decodex/skills/decodex/SKILL.md index 57d965938..c86b55652 100644 --- a/plugins/decodex/skills/decodex/SKILL.md +++ b/plugins/decodex/skills/decodex/SKILL.md @@ -17,9 +17,10 @@ specs. Decodex has these supported use modes: landing, status inspection, project registration, account selection, or dry-run checks. - Automation mode: Decodex owns retained-lane execution through registered project - contracts, `serve`, `run`, tracker labels, issue-scoped tools, review handoff, + contracts, `serve`, `run`, Program Intake, tracker labels for ordinary issues, + issue-scoped tools, review handoff, landing, closeout, and operator status. -- Planning support: agents shape Decodex-friendly issue sets, queue strategy, +- Planning support: agents shape Decodex-friendly issue sets, dispatch readiness, dependency boundaries, and concurrency after a human request or accepted/promoted Decision Contract needs executable issue shaping. @@ -44,11 +45,14 @@ Route by intent: of starting work. 3. After promotion, use `planning` to convert the accepted contract into normal Linear issues with clear natural-language briefs, dependencies, acceptance, and - validation. Keep Execution Program and graph mechanics as internal readiness state. -4. Use `labels` and `automation` only for nodes/issues that are ready under the - registered project policy. Queue labels are an intake signal for retained lanes, - not a shortcut around blockers, opt-outs, terminal states, active leases, or - missing briefing. + validation, then persist an Execution Program for direct scheduler dispatch. Keep + Execution Program and graph mechanics as internal readiness state. +4. Use `automation` for Program Intake and retained execution. Persisted Program + Intake dispatches ready mapped nodes directly with Program dispatch mode. Use + `labels` only for ordinary non-Program issues that should enter service-scoped + tracker intake. Queue labels are not the Program DAG scheduler; they remain an + ordinary issue intake signal and never bypass blockers, opt-outs, terminal states, + active leases, or missing briefing. ## First Steps @@ -60,7 +64,7 @@ Route by intent: directory supplied through a project-scoped command's `--config`. 5. Use the narrow skill for the current action: - `manual-cli` for normal operator CLI use. - - `planning` for Decodex-friendly issue splitting, queue shaping, and concurrency. + - `planning` for Decodex-friendly issue splitting, dispatch readiness, and concurrency. - `automation` for retained-lane control-plane use. - `commit` for `decodex commit`. - `land` for `decodex land`. diff --git a/plugins/decodex/skills/planning/SKILL.md b/plugins/decodex/skills/planning/SKILL.md index 46c2b826d..2e0039164 100644 --- a/plugins/decodex/skills/planning/SKILL.md +++ b/plugins/decodex/skills/planning/SKILL.md @@ -1,6 +1,6 @@ --- name: planning -description: Use when shaping Decodex-friendly issue sets, queue strategy, project capacity, dependencies, or concurrency. Helps agents split work so retained lanes can run independently without overlapping ownership or bypassing registered project policy. +description: Use when shaping Decodex-friendly issue sets, dispatch readiness, project capacity, dependencies, or concurrency. Helps agents split work so retained lanes can run independently without overlapping ownership or bypassing registered project policy. --- # Planning @@ -10,9 +10,10 @@ description: Use when shaping Decodex-friendly issue sets, queue strategy, proje Shape work so Decodex can run the right lanes in parallel while each issue remains independently executable, reviewable, and recoverable. -Use this before queueing a broad feature, migration, or cleanup effort into Decodex. +Use this before shaping a broad feature, migration, or cleanup effort into Decodex. For durable issue text, pair this skill with the delivery plugin's `split` or `issue` -skill, then use the `labels` skill when applying Decodex intake labels. +skill. Use the `labels` skill only for ordinary non-Program issues that should enter +the service-scoped tracker queue. Use this after a natural-language promotion follow-up such as `arrange this`, `push this forward`, `推进`, or `做` when the accepted Decision Contract is ready to @@ -68,39 +69,44 @@ brief. - Split by ownership boundary, validation surface, or deployable behavior, not by arbitrary chronology. -- Prefer several independent queued issues over one broad issue when their file sets, +- Prefer several independent issue lanes over one broad issue when their file sets, contracts, and verification can be isolated. - Put shared contracts, schema changes, and cross-cutting migrations in a foundation - issue first; queue downstream slices only after the blocking contract lands or mark - the dependency explicitly in the tracker. -- Avoid queueing issues that are likely to edit the same hot files, branch lineage, + issue first; let Program DAG dependencies hold downstream slices until the blocking + contract lands, or mark the dependency explicitly in the tracker for ordinary issue + queues. +- Avoid dispatching issues that are likely to edit the same hot files, branch lineage, config authority, or generated artifacts unless the ordering is explicit. - Keep one issue responsible for any user-facing wording or spec contract that several implementation slices depend on, then link the other issues to that authority. -- Use `decodex:queued:` only for issues that are startable under the - registered `WORKFLOW.md`; the label does not bypass state, blocker, terminal-state, - active-lease, or capacity checks. +- Use `decodex:queued:` only for ordinary issues that are startable under + the registered `WORKFLOW.md`; the label does not bypass state, blocker, + terminal-state, active-lease, or capacity checks. Program DAG nodes do not need this + label; persisted Execution Programs dispatch ready mapped nodes directly. - Set `[execution] max_concurrent_agents = 0` only when the project is intentionally uncapped. Use a positive value when the repo, accounts, CI budget, or review surface needs bounded parallelism. -## Queue Shaping +## Dispatch Shaping 1. Use `decodex status` or the dashboard to read active lanes, queued candidates, - retry waits, review waits, landing state, recovery worktrees, cleanup debt, and - available capacity. + Program Intake dispatchability, retry waits, review waits, landing state, recovery + worktrees, cleanup debt, and available capacity. 2. Use `decodex run --dry-run` to confirm project loading, issue discovery, eligibility, dependency blockers, and worktree planning before live automation. -3. Queue only the next independent slice set. Leave future or blocked slices unqueued - until the dependency is terminal or the `WORKFLOW.md` policy makes them startable. +3. For ordinary issue queues, label only the next independent slice set. For Program + DAGs, persist the whole accepted graph and let dispatchability select the ready + subset without manual queue labels. Leave future or blocked slices held until the + dependency is terminal or the `WORKFLOW.md` policy makes them startable. 4. If capacity is finite, keep the highest-value independent slices queued first instead of flooding the queue with dependent work. 5. When a lane stops with `decodex:needs-attention`, resolve the recorded blocker before clearing the label or re-queueing. -6. When the source is a promoted Decision Contract, queue only mapped issues whose - dependencies, conflict domains, acceptance, validation expectations, and registered - workflow state make them ready. Leave blocked, stale, paused, active, terminal, or - unmapped nodes unqueued. +6. When the source is a promoted Decision Contract, do not manually queue mapped + Program issues. Persist the Execution Program and let the scheduler directly + dispatch nodes whose dependencies, conflict domains, acceptance, validation + expectations, and registered workflow state make them ready. Blocked, stale, + paused, active, terminal, or unmapped nodes stay held. ## Boundaries