Skip to main content

vtcode_core/tools/registry/
completion_verification.rs

1//! Runtime verification evidence, independent of compactable source exposure.
2
3use std::collections::{HashSet, VecDeque};
4
5use serde_json::Value;
6
7use super::ToolRegistry;
8use crate::config::constants::tools;
9use crate::tools::{command_args, tool_intent};
10
11const OPTIONAL_EVIDENCE_LIMIT: usize = 32;
12
13#[derive(Debug)]
14struct VerifierEvidence {
15    identity: u64,
16    generation: u64,
17    command: String,
18    session_id: Option<String>,
19    exit_code: Option<i64>,
20}
21
22struct RestoredToolCall<'a> {
23    name: &'a str,
24    args: Value,
25}
26
27#[derive(Clone, Copy)]
28enum MutationEvidenceSource {
29    Live,
30    Restored,
31}
32
33#[derive(Debug, Default)]
34pub(super) struct CompletionVerification {
35    generation: u64,
36    history_initialized: bool,
37    next_identity: u64,
38    evidence: VecDeque<VerifierEvidence>,
39    required_commands: Vec<String>,
40    pending_mutation_sessions: HashSet<String>,
41}
42
43impl CompletionVerification {
44    fn mutate(&mut self) {
45        self.generation = self.generation.saturating_add(1);
46    }
47
48    fn launch(&mut self, command: String) -> u64 {
49        self.next_identity = self.next_identity.saturating_add(1);
50        // New launches supersede older evidence for the same command.
51        self.evidence.retain(|entry| entry.command != command);
52        self.evidence.push_back(VerifierEvidence {
53            identity: self.next_identity,
54            generation: self.generation,
55            command,
56            session_id: None,
57            exit_code: None,
58        });
59        self.prune_optional_evidence();
60        self.next_identity
61    }
62
63    fn prune_optional_evidence(&mut self) {
64        let mut pinned = HashSet::new();
65        for command in &self.required_commands {
66            let command = executed_command_identity(command);
67            let mut newest = true;
68            for entry in self
69                .evidence
70                .iter()
71                .rev()
72                .filter(|entry| command_covers_required(&entry.command, &command))
73            {
74                // Keep the newest verdict for each requirement and retain live
75                // handles until their terminal result or cleanup is observed.
76                if newest || entry.session_id.is_some() {
77                    pinned.insert(entry.identity);
78                }
79                newest = false;
80            }
81        }
82        let mut to_remove = self
83            .evidence
84            .iter()
85            .filter(|entry| !pinned.contains(&entry.identity))
86            .count()
87            .saturating_sub(OPTIONAL_EVIDENCE_LIMIT);
88        // Required verdicts and yielded handles remain available until their
89        // command is replaced or released by the tracker.
90        self.evidence.retain(|entry| {
91            if to_remove == 0 || pinned.contains(&entry.identity) {
92                true
93            } else {
94                to_remove -= 1;
95                false
96            }
97        });
98    }
99
100    fn observe(&mut self, identity: u64, output: &Value) {
101        let Some(evidence) = self.evidence.iter_mut().find(|entry| entry.identity == identity) else {
102            return;
103        };
104        if output.get("error").is_some()
105            || output.get("success").and_then(Value::as_bool) == Some(false)
106            || ["blocked", "not_executed", "cancelled", "termination_requested"]
107                .iter()
108                .any(|key| output.get(key).and_then(Value::as_bool) == Some(true))
109        {
110            evidence.session_id = None;
111            evidence.exit_code = None;
112            self.prune_optional_evidence();
113            return;
114        }
115        if evidence
116            .session_id
117            .as_deref()
118            .is_some_and(|expected| command_args::session_id_text(output).is_some_and(|returned| returned != expected))
119        {
120            return;
121        }
122        if let Some(exit_code) = output.get("exit_code").and_then(Value::as_i64) {
123            evidence.exit_code = Some(exit_code);
124            evidence.session_id = None;
125        } else if let Some(session_id) = command_args::session_id_text(output) {
126            evidence.session_id = Some(session_id.to_owned());
127        }
128        self.prune_optional_evidence();
129    }
130
131    fn command_passed(&self, command: &str) -> bool {
132        let command = executed_command_identity(command);
133        self.evidence
134            .iter()
135            .rev()
136            .find(|entry| command_covers_required(&entry.command, &command))
137            .is_some_and(|entry| entry.generation == self.generation && entry.exit_code == Some(0))
138    }
139
140    fn requires_command(&self, command: &str) -> bool {
141        let command = executed_command_identity(command);
142        self.required_commands
143            .iter()
144            .any(|required| command_covers_required(&command, &executed_command_identity(required)))
145    }
146
147    fn pending(&self) -> bool {
148        if !self.pending_mutation_sessions.is_empty() {
149            return true;
150        }
151        if self.generation == 0 {
152            return false;
153        }
154        if !self.required_commands.is_empty() {
155            return self.required_commands.iter().any(|command| !self.command_passed(command));
156        }
157        !self
158            .evidence
159            .back()
160            .is_some_and(|entry| entry.generation == self.generation && entry.exit_code == Some(0))
161    }
162}
163
164fn executed_command_identity(command: &str) -> String {
165    let args = serde_json::json!({"action":"run", "command":command});
166    let executed = tool_intent::shell_args_as_executed(tools::UNIFIED_EXEC, &args);
167    command_args::raw_command_text(&executed)
168        .unwrap_or_else(|| command.to_owned())
169        .trim()
170        .to_owned()
171}
172
173fn command_covers_required(command: &str, required: &str) -> bool {
174    command == required
175        || (command.contains("&&") && tool_intent::activity::shell_command_covers_required_check(command, required))
176}
177
178fn is_session_cleanup(args: &Value) -> bool {
179    tool_intent::is_exec_session_cleanup_call(tools::WRITE_STDIN, args)
180        || matches!(tool_intent::command_session_action(args), Some("close" | "terminate"))
181}
182
183fn mutation_targets_are_exempt(name: &str, args: &Value, output: &Value) -> bool {
184    let exempt = |tool_name: &str, targets: &Value| {
185        tool_intent::is_plan_artifact_write(tool_name, targets) || tool_intent::is_docs_only_write(tool_name, targets)
186    };
187    if !exempt(name, args) {
188        return false;
189    }
190    // Built-in file results report canonical targets. Requested prose/planning
191    // aliases must not exempt writes through symlinks to code.
192    let effective_paths: Vec<_> = ["path", "from", "to"]
193        .iter()
194        .filter_map(|key| output.get(*key).and_then(Value::as_str))
195        .chain(
196            output
197                .get("modified_files")
198                .and_then(Value::as_array)
199                .into_iter()
200                .flatten()
201                .filter_map(Value::as_str),
202        )
203        .collect();
204    effective_paths.is_empty() || exempt(tools::WRITE_FILE, &serde_json::json!({"paths":effective_paths}))
205}
206
207impl ToolRegistry {
208    /// Reconstruct mutation debt once from restored history; historical checks are not runtime verdicts.
209    pub fn restore_completion_verification_from_history(&self, history: &[crate::llm::provider::Message]) {
210        use crate::llm::provider::MessageRole;
211        {
212            let mut state = self.harness_context.completion_verification.lock();
213            if state.history_initialized {
214                return;
215            }
216            state.history_initialized = true;
217            // A live registry already owns reliable runtime evidence. Only a
218            // newly restored registry reconstructs debt from archived messages.
219            if state.generation != 0 || !state.evidence.is_empty() {
220                return;
221            }
222        }
223        let mut pending = std::collections::HashMap::new();
224        for message in history {
225            if message.role == MessageRole::Assistant {
226                pending.clear();
227                for call in message.tool_calls.iter().flatten() {
228                    if let Some(function) = &call.function
229                        && let Ok(args) = serde_json::from_str::<Value>(&function.arguments)
230                    {
231                        pending.insert(call.id.as_str(), RestoredToolCall { name: function.name.as_str(), args });
232                    }
233                }
234            } else if message.role == MessageRole::Tool
235                && let Some(call) = message.tool_call_id.as_deref().and_then(|id| pending.remove(id))
236                && let Ok(output) = serde_json::from_str::<Value>(message.content.as_text().as_ref())
237            {
238                if self.tracker_adopted_for_request() && call.name == tools::TASK_TRACKER {
239                    self.observe_required_completion_verifiers(&output);
240                }
241                if output.get("cleared").is_some() {
242                    self.observe_completion_mutation_from(
243                        call.name,
244                        &call.args,
245                        &serde_json::json!({"success":true,"exit_code":0}),
246                        MutationEvidenceSource::Restored,
247                    );
248                } else {
249                    self.observe_completion_mutation_from(
250                        call.name,
251                        &call.args,
252                        &output,
253                        MutationEvidenceSource::Restored,
254                    );
255                }
256            }
257        }
258    }
259
260    /// Code changes require an observed exit zero from the same mutation generation.
261    pub fn completion_verification_pending(&self) -> bool {
262        self.harness_context.completion_verification.lock().pending()
263    }
264
265    pub(super) fn observe_required_completion_verifiers(&self, output: &Value) {
266        let Some(items) = output
267            .get("checklist")
268            .and_then(|checklist| checklist.get("items"))
269            .and_then(Value::as_array)
270        else {
271            return;
272        };
273        let mut commands = Vec::new();
274        for command in items
275            .iter()
276            .filter_map(|item| item.get("verify"))
277            .filter_map(Value::as_array)
278            .flatten()
279            .filter_map(Value::as_str)
280        {
281            let command = command.trim();
282            if !command.is_empty() && !commands.iter().any(|existing| existing == command) {
283                commands.push(command.to_owned());
284            }
285        }
286        self.set_required_completion_verifiers(commands);
287    }
288
289    /// Existing tracker verification commands are mandatory, even when another check passes.
290    pub(super) fn set_required_completion_verifiers(&self, commands: Vec<String>) {
291        let mut state = self.harness_context.completion_verification.lock();
292        state.required_commands = commands;
293        state.prune_optional_evidence();
294    }
295
296    pub fn completion_verifier_passed(&self, command: &str) -> bool {
297        self.harness_context.completion_verification.lock().command_passed(command)
298    }
299
300    pub(crate) fn missing_required_completion_verifiers(&self) -> Vec<String> {
301        let state = self.harness_context.completion_verification.lock();
302        state
303            .required_commands
304            .iter()
305            .filter(|command| !state.command_passed(command))
306            .cloned()
307            .collect()
308    }
309
310    /// Resume a current-generation verifier covering the requested check.
311    pub fn pending_completion_verifier_session(&self, command: &str) -> Option<String> {
312        let command = executed_command_identity(command);
313        let state = self.harness_context.completion_verification.lock();
314        state
315            .evidence
316            .iter()
317            .rev()
318            .find(|entry| command_covers_required(&entry.command, &command) && entry.generation == state.generation)
319            .and_then(|entry| entry.session_id.clone())
320    }
321
322    pub fn next_required_completion_verifier(&self) -> Option<String> {
323        let state = self.harness_context.completion_verification.lock();
324        state
325            .required_commands
326            .iter()
327            .find(|command| !state.command_passed(command))
328            .cloned()
329    }
330
331    /// Restore conservatively: historical success cannot establish fresh runtime evidence.
332    /// Call once when restoring a session with recorded code changes or verification debt.
333    pub fn restore_completion_verification_debt(&self) {
334        let mut state = self.harness_context.completion_verification.lock();
335        if state.generation == 0 {
336            state.mutate();
337        }
338    }
339
340    pub fn unverified_completion_reason(&self) -> String {
341        let state = self.harness_context.completion_verification.lock();
342        let evidence = state
343            .evidence
344            .back()
345            .map(|entry| {
346                format!(
347                    " Last command: `{}`; terminal exit: {:?}; launch generation: {}; current generation: {}.",
348                    entry.command, entry.exit_code, entry.generation, state.generation
349                )
350            })
351            .unwrap_or_else(|| " No terminal verifier evidence was observed.".to_string());
352        let remaining = state.required_commands.iter().find(|command| !state.command_passed(command));
353        let required = remaining.map_or_else(String::new, |command| {
354            let preview = vtcode_commons::formatting::truncate_within(command, 1024, "...");
355            format!(
356                " Next required tracker check: `{preview}`. Run this exact command; a different passing check does not satisfy it."
357            )
358        });
359        format!(
360            "Verification is still pending: Unverified: changed code requires fresh verification before successful completion.{evidence}{required}"
361        )
362    }
363
364    pub(super) fn begin_completion_verifier(&self, args: &Value) -> Option<u64> {
365        let command = self.completion_verifier_command(args)?;
366        Some(self.harness_context.completion_verification.lock().launch(command))
367    }
368
369    /// Share verifier accounting with the pacing gate; this grants no execution permission.
370    pub fn is_completion_verifier_call(&self, name: &str, args: &Value) -> bool {
371        tool_intent::is_command_tool(crate::tools::names::canonical_tool_name(name))
372            && self.completion_verifier_command(args).is_some()
373    }
374
375    fn completion_verifier_command(&self, args: &Value) -> Option<String> {
376        let executed = tool_intent::shell_args_as_executed(tools::UNIFIED_EXEC, args);
377        let command = command_args::raw_command_text(&executed)?.trim().to_owned();
378        // A declared repair action still invalidates verification. Inspect all
379        // static segments so a harmless prefix cannot hide a source rewrite.
380        if tool_intent::readonly::static_shell_command_words(&command)
381            .or_else(|| tool_intent::readonly::static_shell_command_words_with_output_plumbing(&command))
382            .is_some_and(|segments| segments.iter().any(|words| command_args::command_uses_source_repair(words)))
383        {
384            return None;
385        }
386        if tool_intent::classify_shell_activity(tools::UNIFIED_EXEC, &executed)
387            != tool_intent::ShellActivity::Verification
388            && !self.harness_context.completion_verification.lock().requires_command(&command)
389        {
390            return None;
391        }
392        let cwd = self
393            .pty_manager()
394            .working_dir_candidate(command_args::working_dir_text(args))
395            .ok()?;
396        if cwd != *self.workspace_root() {
397            return None;
398        }
399        // Empty argv/environment containers are native adapter defaults. Effective
400        // launch context cannot be dropped from a command-only verifier identity.
401        if args
402            .get("args")
403            .is_some_and(|value| !matches!(value, Value::Array(args) if args.is_empty()))
404            || args.get("env").is_some_and(|value| match value {
405                Value::Array(entries) => !entries.is_empty(),
406                Value::Object(env) => !env.is_empty(),
407                _ => true,
408            })
409            || ["shell", "login", "stdin"].iter().any(|key| args.get(key).is_some())
410        {
411            return None;
412        }
413        Some(command)
414    }
415
416    pub(super) fn observe_completion_verifier(
417        &self,
418        launch: Option<u64>,
419        args: &Value,
420        result: &anyhow::Result<Value>,
421    ) {
422        let mut state = self.harness_context.completion_verification.lock();
423        if let Some(session_id) = command_args::session_id_text(args)
424            && state.pending_mutation_sessions.contains(session_id)
425            && result.as_ref().is_ok_and(|output| {
426                !command_args::session_id_text(output).is_some_and(|returned| returned != session_id)
427                    && (output.get("exit_code").and_then(Value::as_i64).is_some()
428                        || (is_session_cleanup(args)
429                            && output.get("error").is_none()
430                            && output.get("success").and_then(Value::as_bool) != Some(false)))
431            })
432        {
433            state.pending_mutation_sessions.remove(session_id);
434            // A check run while a mutator was active cannot verify its final output.
435            state.mutate();
436        }
437        let identity = launch.or_else(|| {
438            let session_id = command_args::session_id_text(args)?;
439            if tool_intent::classify_tool_intent(tools::UNIFIED_EXEC, args).mutating && !is_session_cleanup(args) {
440                return None;
441            }
442            state
443                .evidence
444                .iter()
445                .rev()
446                .find(|entry| entry.session_id.as_deref() == Some(session_id))
447                .map(|entry| entry.identity)
448        });
449        let Some(identity) = identity else {
450            return;
451        };
452        match result {
453            Ok(output) if !is_session_cleanup(args) => state.observe(identity, output),
454            _ => {
455                // Lost, denied, cancelled, or cleaned-up sessions carry no verdict.
456                if let Some(entry) = state.evidence.iter_mut().find(|entry| entry.identity == identity) {
457                    entry.session_id = None;
458                    entry.exit_code = None;
459                }
460                state.prune_optional_evidence();
461            }
462        }
463    }
464
465    pub(super) fn observe_completion_mutation(&self, name: &str, args: &Value, output: &Value) {
466        self.observe_completion_mutation_from(name, args, output, MutationEvidenceSource::Live);
467    }
468
469    /// Retire verification bookkeeping for exec sessions removed by bulk
470    /// cleanup paths (`terminate_*_exec_sessions_async`, TUI force-cancel)
471    /// that bypass per-call observation. Without this, a terminated mutating
472    /// session stays in `pending_mutation_sessions` forever and every later
473    /// turn reports unverified completion even after fresh checks pass.
474    /// Reuses the single-session cleanup contract (retire the pending boundary
475    /// and any live verifier handle); unknown or still-live ids are harmless
476    /// no-ops. Callers must pass only sessions that are actually gone so a
477    /// live mutator is never released early.
478    pub(super) fn observe_completion_exec_sessions_terminated(&self, session_ids: &[String]) {
479        for session_id in session_ids {
480            let args = serde_json::json!({"action":"close", "session_id":session_id});
481            let result: anyhow::Result<Value> = Ok(serde_json::json!({"success":true}));
482            self.observe_completion_verifier(None, &args, &result);
483        }
484    }
485
486    fn observe_completion_mutation_from(
487        &self,
488        name: &str,
489        args: &Value,
490        output: &Value,
491        source: MutationEvidenceSource,
492    ) {
493        let name = crate::tools::names::canonical_tool_name(name);
494        // Mutation intent also covers coordination and metadata tools. Only
495        // workspace writes, shell mutations, or explicit changed paths incur code debt.
496        if !tool_intent::is_command_tool(name)
497            && !tool_intent::is_edited_file_conflict_guarded_call(name, args)
498            && !matches!(
499                name,
500                tools::DELETE_FILE | tools::MOVE_FILE | tools::COPY_FILE | tools::SEARCH_REPLACE | tools::UNIFIED_FILE
501            )
502            && !output
503                .get("modified_files")
504                .and_then(Value::as_array)
505                .is_some_and(|files| !files.is_empty())
506        {
507            return;
508        }
509        let exec_changed = tool_intent::is_command_tool(name) && tool_intent::activity::exec_output_has_changes(output);
510        if !exec_changed
511            && (mutation_targets_are_exempt(name, args, output)
512                || !tool_intent::classify_tool_intent(name, args).mutating
513                || output.get("error").is_some()
514                || output.get("success").and_then(Value::as_bool) == Some(false)
515                || ["blocked", "not_executed", "cancelled"]
516                    .iter()
517                    .any(|key| output.get(key).and_then(Value::as_bool) == Some(true)))
518        {
519            return;
520        }
521        if tool_intent::is_command_tool(name) && !exec_changed {
522            let executed = tool_intent::shell_args_as_executed(name, args);
523            if tool_intent::classify_shell_activity(name, &executed) != tool_intent::ShellActivity::Mutation
524                || tool_intent::shell_command_is_admitted_verification_attempt(&executed)
525                // Declared checks use the same eligibility rules as verifier launch.
526                // This affects accounting only; execution admission remains authoritative.
527                || self.completion_verifier_command(args).is_some()
528                || (output.get("exit_code").and_then(Value::as_i64) != Some(0)
529                    && command_args::session_id_text(output).is_none())
530            {
531                return;
532            }
533            // Dynamic inspections need a complete nonmutation proof before
534            // they may preserve prior verifier evidence. Admission is unchanged.
535            if let Some(command_text) = command_args::raw_command_text(&executed)
536                .or_else(|| command_args::shell_command_text_with_args(&executed))
537                && !tool_intent::activity::exec_mutation_should_incur_debt(&command_text, output)
538            {
539                return;
540            }
541        }
542        match crate::tools::file_ops::diff_output_has_effective_change(output) {
543            Some(false) if !exec_changed => return,
544            Some(false) | Some(true) => {}
545            None if output.get("success").and_then(Value::as_bool) != Some(true)
546                && output.get("exit_code").and_then(Value::as_i64) != Some(0)
547                && command_args::session_id_text(output).is_none()
548                && !output
549                    .get("modified_files")
550                    .and_then(Value::as_array)
551                    .is_some_and(|files| !files.is_empty()) =>
552            {
553                return;
554            }
555            None => {}
556        }
557        let mut state = self.harness_context.completion_verification.lock();
558        state.mutate();
559        if matches!(source, MutationEvidenceSource::Live)
560            && tool_intent::is_command_tool(name)
561            && output.get("exit_code").and_then(Value::as_i64).is_none()
562            && let Some(session_id) = command_args::session_id_text(output)
563        {
564            state.pending_mutation_sessions.insert(session_id.to_owned());
565        }
566    }
567}
568
569#[cfg(test)]
570mod tests;