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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/decodex/src/agent/tracker_tool_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,15 @@ pub(crate) enum ReviewExecutionMode {
Repair,
Closeout,
}
impl ReviewExecutionMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Handoff => "handoff",
Self::Repair => "repair",
Self::Closeout => "closeout",
}
}
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum TurnCompletionStatus {
Expand All @@ -932,7 +941,7 @@ pub(crate) enum RunCompletionDisposition {
Closeout,
}
impl RunCompletionDisposition {
fn as_str(self) -> &'static str {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::ManualAttention => "manual_attention",
Self::ReviewHandoff => "review_handoff",
Expand Down
27 changes: 27 additions & 0 deletions apps/decodex/src/agent/tracker_tool_bridge/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,33 @@ impl<'a> TrackerToolBridge<'a> {
}
}

pub(crate) fn finalized_completion_disposition(
&self,
) -> crate::prelude::Result<Option<RunCompletionDisposition>> {
let Some(finalized_path) = *self.finalized_completion_path.borrow() else {
return Ok(None);
};
let completion_path = self.completion_disposition()?;

if finalized_path != completion_path {
let Some(review_context) = self.review_context.as_ref() else {
eyre::bail!(
"Review handoff context is unavailable for issue `{}`.",
self.issue.identifier
);
};

eyre::bail!(
"Run `{}` finalized terminal path `{}`, but the recorded terminal path resolved to `{}` after app-server failure.",
review_context.run_id,
finalized_path.as_str(),
completion_path.as_str()
);
}

Ok(Some(finalized_path))
}

pub(crate) fn apply_review_handoff(&self) -> crate::prelude::Result<()> {
let Some(review_context) = self.review_context.as_ref() else {
eyre::bail!(
Expand Down
22 changes: 22 additions & 0 deletions apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ fn terminal_finalize_accepts_matching_review_handoff_path() {
})]);
let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]);
let review_context = sample_review_context_in(temp_dir.path());
let run_id = review_context.run_id.clone();
let attempt_number = review_context.attempt_number;

write_clean_review_checkpoint(&review_context);

Expand Down Expand Up @@ -94,9 +96,29 @@ fn terminal_finalize_accepts_matching_review_handoff_path() {

assert!(review_response.success);
assert!(finalize_response.success);
assert_eq!(
bridge
.finalized_completion_disposition()
.expect("finalized disposition should resolve"),
Some(RunCompletionDisposition::ReviewHandoff)
);

DynamicToolHandler::validate_turn_completion(&bridge, "done")
.expect("matching finalization should allow the turn to complete");

let events = bridge_state_store(&bridge)
.list_private_execution_events(TEST_SERVICE_ID, &issue.id, &run_id, attempt_number)
.expect("private terminal events should read");

assert!(events.iter().any(|event| {
event.event_type() == "review_completion_intent"
&& event.payload()["path"] == "review_handoff"
&& event.payload()["pr_url"] == "https://github.com/hack-ink/decodex/pull/53"
}));
assert!(events.iter().any(|event| {
event.event_type() == "terminal_finalize"
&& event.payload()["path"] == "review_handoff"
}));
}

#[test]
Expand Down
116 changes: 115 additions & 1 deletion apps/decodex/src/agent/tracker_tool_bridge/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME,
ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, LabelArgs,
NormalizedProgressCheckpoint, NormalizedReviewCheckpointPayload, PendingReviewAction,
PendingReviewCompletion, ProgressCheckpointArgs, ReviewCheckpointArgs,
PendingReviewCompletion, ProgressCheckpointArgs, PullRequestDetails, ReviewCheckpointArgs,
ReviewCheckpointChecksArgs, ReviewCheckpointFindingArgs,
ReviewCheckpointRejectedFindingArgs, ReviewExecutionMode, ReviewHandoffArgs,
ReviewHandoffContext, ReviewPolicyPhase, ReviewPolicyStatus, RunCompletionDisposition,
Expand All @@ -29,6 +29,8 @@ use crate::{
const COMMENT_KIND_MANUAL_ATTENTION: &str = "manual_attention";
const MANUAL_ATTENTION_TERMINAL_PATH: &str = "manual_attention";
const INDEPENDENT_FRESH_CONTEXT_REVIEWER: &str = "independent_fresh_context";
const REVIEW_COMPLETION_INTENT_EVENT_TYPE: &str = "review_completion_intent";
const TERMINAL_FINALIZE_EVENT_TYPE: &str = "terminal_finalize";

#[derive(Debug)]
struct NormalizedManualAttentionComment {
Expand Down Expand Up @@ -1417,6 +1419,84 @@ impl<'a> TrackerToolBridge<'a> {
})
}

fn append_review_completion_intent(
&self,
review_context: &ReviewHandoffContext,
path: RunCompletionDisposition,
pull_request: &PullRequestDetails,
summary: &str,
) -> Result<(), String> {
let state_store = self.state_store.ok_or_else(|| {
format!(
"`{}` requires the Decodex runtime state store for issue `{}`.",
self.required_pr_completion_tool_name(),
self.issue.identifier
)
})?;

state_store
.append_private_execution_event(
&review_context.service_id,
&self.issue.id,
&review_context.run_id,
review_context.attempt_number,
REVIEW_COMPLETION_INTENT_EVENT_TYPE,
serde_json::json!({
"path": path.as_str(),
"mode": review_context.mode.as_str(),
"branch": review_context.branch_name.as_str(),
"worktree_path": review_context.worktree_path.as_str(),
"pr_url": pull_request.url.as_str(),
"pr_base_ref": pull_request.base_ref_name.as_str(),
"pr_head_ref": pull_request.head_ref_name.as_str(),
"pr_head_oid": pull_request.head_ref_oid.as_str(),
"summary": summary,
}),
)
.map(|_| ())
.map_err(|error| {
format!(
"Failed to persist review completion intent for issue `{}`: {error}",
self.issue.identifier
)
})
}

fn append_terminal_finalize_event(
&self,
review_context: &ReviewHandoffContext,
path: RunCompletionDisposition,
) -> Result<(), String> {
let state_store = self.state_store.ok_or_else(|| {
format!(
"`{ISSUE_TERMINAL_FINALIZE_TOOL_NAME}` requires the Decodex runtime state store for issue `{}`.",
self.issue.identifier
)
})?;

state_store
.append_private_execution_event(
&review_context.service_id,
&self.issue.id,
&review_context.run_id,
review_context.attempt_number,
TERMINAL_FINALIZE_EVENT_TYPE,
serde_json::json!({
"path": path.as_str(),
"mode": review_context.mode.as_str(),
"branch": review_context.branch_name.as_str(),
"worktree_path": review_context.worktree_path.as_str(),
}),
)
.map(|_| ())
.map_err(|error| {
format!(
"Failed to persist terminal finalize intent for issue `{}`: {error}",
self.issue.identifier
)
})
}

fn clear_review_policy_state_after_completion(
&self,
review_context: &ReviewHandoffContext,
Expand Down Expand Up @@ -1512,6 +1592,14 @@ impl<'a> TrackerToolBridge<'a> {
) {
return DynamicToolCallResponse::failure(error);
}
if let Err(error) = self.append_review_completion_intent(
review_context,
RunCompletionDisposition::ReviewHandoff,
&pull_request,
&summary,
) {
return DynamicToolCallResponse::failure(error);
}

self.pending_review_completion.borrow_mut().replace(PendingReviewCompletion::Handoff(
PendingReviewAction { pr_url: pull_request.url.clone(), summary },
Expand Down Expand Up @@ -1584,6 +1672,14 @@ impl<'a> TrackerToolBridge<'a> {
) {
return DynamicToolCallResponse::failure(error);
}
if let Err(error) = self.append_review_completion_intent(
review_context,
RunCompletionDisposition::ReviewRepair,
&pull_request,
&summary,
) {
return DynamicToolCallResponse::failure(error);
}

self.pending_review_completion.borrow_mut().replace(PendingReviewCompletion::Repair(
PendingReviewAction { pr_url: pull_request.url.clone(), summary },
Expand Down Expand Up @@ -1645,6 +1741,14 @@ impl<'a> TrackerToolBridge<'a> {
if let Err(error) = self.validate_closeout_issue_completed_state() {
return DynamicToolCallResponse::failure(error);
}
if let Err(error) = self.append_review_completion_intent(
review_context,
RunCompletionDisposition::Closeout,
&pull_request,
&summary,
) {
return DynamicToolCallResponse::failure(error);
}

self.pending_review_completion.borrow_mut().replace(PendingReviewCompletion::Closeout(
PendingReviewAction { pr_url: pull_request.url.clone(), summary },
Expand Down Expand Up @@ -1780,6 +1884,16 @@ impl<'a> TrackerToolBridge<'a> {
));
}

let Some(review_context) = self.review_context.as_ref() else {
return DynamicToolCallResponse::failure(format!(
"`{ISSUE_TERMINAL_FINALIZE_TOOL_NAME}` is unavailable for this run."
));
};

if let Err(error) = self.append_terminal_finalize_event(review_context, actual_path) {
return DynamicToolCallResponse::failure(error);
}

self.finalized_completion_path.replace(Some(actual_path));

DynamicToolCallResponse::success(format!(
Expand Down
Loading