feat: Autopilot Supervisor v2 — full implementation (M0–M7 + DoD)#36
Conversation
Co-authored-by: chinkan.ai <chinkan@gmail.com>
…-M7 + DoD) Co-authored-by: chinkan.ai <chinkan@gmail.com>
|
Claude finished @chinkan's task in 3m 8s —— View job PR #36 Code Review — Autopilot Supervisor v2 (docs)
|
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
…th deferred to M3) Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
…→artifacts) Co-authored-by: chinkan.ai <chinkan@gmail.com>
… decode (review) Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
…orts) Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
… (review) Co-authored-by: chinkan.ai <chinkan@gmail.com>
…review) Co-authored-by: chinkan.ai <chinkan@gmail.com>
Co-authored-by: chinkan.ai <chinkan@gmail.com>
@copilot please fix the all C S D A parts. |
C1: Evidence::ExitCode tuple variant → struct variant {code: i32} for
serde internally-tagged compat; fixes latent serialization panic.
Updated all 6 construction/match sites.
C2: required_capabilities never persisted in sup_tasks.
- Added required_capabilities TEXT column to DB schema
- Added to INSERT in store::create()
- Added to SELECT+deserialize in store::get()
- Added to UPDATE in store::update_classification()
C3: Orchestrator ultimate fallback to reasoning backend when both
select_by_name and select_for(capability) return None; prevents
coding/research/document tasks failing silently with no backend.
C4: Replace (_, Cancelled) catch-all with explicit non-terminal
from-states; Done→Cancelled and Failed→Cancelled now disallowed.
Also added Route/Plan/PrepareWorkspace→Paused for pause() callers.
Added debug_assert in record_transition to catch violations in tests.
Added test assertions for !transition_allowed(Done, Cancelled) etc.
D1: Filter jobs by current plan IDs before verification in execute_now()
so resumed tasks don't see orphan rows from aborted prior runs.
A1: Extract shared run_cli_process() helper to backend/mod.rs;
ClaudeCodeCliBackend, CodexCliBackend, ScriptBackend all delegate
to it. Gracefully ignores EPIPE on stdin write (process may exit
before reading all stdin).
A2: Fix WorkspaceManager::prepare worktree path — with_extension
replaced by proper parent().join() to avoid mangling repo names.
A3: Replace time-based parallel test with AtomicUsize count assertion.
A4: Rename HeuristicClassifier::classify inherent method to
classify_as_task to disambiguate from Classifier trait impl.
Minor: Fix silent serde_json::from_str().unwrap_or() → map_err in
jobs_for_task (store.rs lines 224, 228).
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/45817859-c1c7-4605-a948-b3798210809c
Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Fixed all C, S (documented), D, and A items in commit f81df5b. Here's a summary: C1 — C2 — C3 — Orchestrator now falls back to C4 — Replaced D1 — A1 — Shared A2 — A3 — Parallel orchestrator test now asserts A4 — Minor — All 275 tests pass, |
|
Claude finished @chinkan's task in 4m 14s —— View job PR #36 Code Review — Autopilot Supervisor v2 (Final Review)
|
| Item | File | Status |
|---|---|---|
C1 — ExitCode { code: i32 } struct variant |
job.rs:30 |
✅ Fixed |
C2 — required_capabilities persisted |
store.rs:68-149 |
✅ Fixed |
C3 — Orchestrator fallback to "reasoning" |
orchestrator.rs:116 |
✅ Fixed |
| C4 — Terminal states block Cancelled | state.rs:14-40, 57-59 |
✅ Fixed |
| D1 — Resume orphan-job filter | mod.rs:134-207 |
✅ Fixed |
A1 — run_cli_process shared helper |
backend/mod.rs:129-191 |
✅ Fixed |
A2 — WorkspaceManager path construction |
workspace.rs:34-44 |
✅ Fixed |
| A3 — Counter-based parallel test | orchestrator.rs:249-253 |
✅ Fixed |
A4 — classify_as_task rename |
classifier.rs:78 |
✅ Fixed |
| I-1,2,3 — Review state, parallel groups, prod registry | mod.rs, orchestrator.rs, main.rs |
✅ Fixed |
New Issues
N1 — execute_now hardcodes Route as from-state — breaks resume audit trail [mod.rs:124-131]
execute_now always opens with record_transition(Route, Plan), but when called via resume(), the task is already in Execute state (just set by the Paused → Execute transition in resume). Since record_transition receives the hardcoded Route (not the actual DB state) as from, the debug_assert!(transition_allowed(from, to)) checks Route → Plan (which is valid and passes), while the DB unconditionally writes Plan. The actual transition is Execute → Plan, which is not a legal transition — but it goes undetected.
The audit log for a resumed task reads: … → Paused → Execute → Plan → …, where Execute → Plan is silently illegal. Functional behaviour is unaffected only because transition enforcement is debug_assert-only.
Fix: read the actual current state from task.status before calling record_transition. For resume, use task.status (which is Execute) as from, not the hardcoded Route.
// mod.rs — execute_now should derive from-state from the loaded task:
let pre_plan_state = task.status.clone(); // Route on first run, Execute on resume
self.store.record_transition(task_id, pre_plan_state, TaskStatus::Plan, ...).await?;N2 — SkillAwareClassifier is dead code in production [classifier.rs:120-148, mod.rs:97-104]
Both Supervisor::new and Supervisor::new_for_test instantiate HeuristicClassifier directly. SkillAwareClassifier — the M5 skill-pack feature — is never used through the production supervisor path. The classify method on SkillAwareClassifier is not part of the Classifier trait (it returns Task, not ClassificationOutcome), so it cannot be used as a Box<dyn Classifier> either.
Effect: the skill packs loaded from skills/sup-*/SKILL.md at startup never influence task routing. M5's supervisor_required_caps override is untriggerable in production.
Fix: Either wire SkillAwareClassifier into Supervisor::new (inject the SkillRegistry), or add a classify_with_skills method to the Classifier trait, or document this as deferred.
Fix this →
N3 — ShellBackend::run has no timeout [backend/shell.rs:61-88]
ShellBackend::run calls Command::output().await (blocking indefinitely) while the three CLI backends all go through run_cli_process, which applies tokio::time::timeout(Duration::from_secs(job.timeout_secs), ...). A sleep 9999 or hung pipe will stall the Tokio task and block the orchestrator.
Fix: Apply the same pattern as run_cli_process:
match tokio::time::timeout(
Duration::from_secs(job.timeout_secs),
Command::new("sh").arg("-c").arg(&cmd).current_dir(&self.sandbox).output(),
).await {
Ok(res) => res?,
Err(_) => return Ok(/* timeout JobOutput */),
}Minor / Accepted Debt
M1 — store.rs:get silently swallows DB errors [store.rs:127-130]
Ok(match rows.next() {
Some(Ok(t)) => Some(t),
_ => None, // ← also catches Some(Err(db_error))
})A deserialization failure on an existing row returns Ok(None) (task not found) instead of propagating the error. Prefer rows.next().transpose()?.
M2 — Unknown + Medium risk auto-executes [policy.rs:33-41]
None of the early-return guards trigger for TaskType::Unknown + RiskLevel::Medium, so it falls through to PolicyDecision::AutoExecute. The Unknown + Low path correctly returns Clarify; Medium should too, since unknown intent with non-trivial risk probably warrants clarification.
M3 — Capability strings are bare literals with no shared constants
"reasoning", "coding", "shell", "research", "document" appear as string literals across planner.rs, classifier.rs, and backend/mod.rs. A typo silently falls back to reasoning (via C3 fix) rather than producing a visible error. Adding pub const CAP_REASONING: &str = "reasoning" etc. in backend/mod.rs would surface mismatches at compile time.
M4 — store.rs:get still hard-codes three fields as Null [store.rs:122-124]
constraints, inputs, and expected_outputs are not included in the SELECT and are always reconstructed as serde_json::Value::Null. This is documented accepted debt, but any future Planner logic that inspects these fields will silently get Null.
What's Working Well
- Secret redaction (
redact.rs) — preserves separator in$2; all test assertions match implementation. - Resume orphan filtering (
mod.rs:134-207) —current_job_idsHashSet correctly excludes prior-run jobs from verification. - Rigorous-mode audit trail —
Execute → Review → Verifycorrectly recorded; DoD test atsupervisor_dod_smoke.rs:147verifies the"REVIEW"row. - Capability routing fallback — orchestrator gracefully falls back to
reasoningfor unregistered capabilities. - Timeout enforcement in CLI backends —
kill_on_drop(true)+tokio::time::timeoutinrun_cli_process. - State machine terminal-state guard — explicit match arms prevent
Done/Failed → Cancelled; tests cover both. run_cli_processdeduplication — 90% duplicate code eliminated; all three CLI backends are thin wrappers.required_capabilitiespersistence —store.rs:133-150correctly UPDATEs the column;store.rs:70-121correctly SELECTs and deserializes it.
Overall Assessment
All critical bugs from the prior review are resolved. The two most impactful remaining issues are N2 (M5 SkillAwareClassifier never fires in production) and N3 (ShellBackend missing timeout). N1 (resume state audit drift) affects audit trail correctness but not task outcomes. Everything else is documented accepted debt. The feature is safe to ship given the Telegram dispatcher isn't wired to the supervisor yet.
- Create src/agent_prompt.rs with PreparedPrompt and PromptStats types - Implement estimate_prompt_chars() to count message content + tool args - Implement recovery_nudge_for() with context-aware nudge messages - Implement prepare_messages_for_llm() with conditional compaction - Implement compact_tool_heavy_history() with tool-group preservation - Add 6 comprehensive tests covering all specified behaviors - Export agent_prompt module in src/lib.rs All tests pass. Compaction only triggers when message count > 10 AND prompt size > 20,000 chars. Preserves newest 2 tool groups unchanged, compacts older tool arguments > 1,000 chars and tool results > 2,000 chars. Maintains message order throughout.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
- Introduced a new design to handle cases where the LLM provider returns an empty response. - Added a response validity boundary to distinguish between valid and invalid responses. - Implemented a retry mechanism for invalid empty responses with a configurable limit. - Added prompt compaction to reduce the size of tool-heavy histories before LLM calls. - Enhanced observability in LangSmith to capture detailed metrics for empty responses and retries. - Updated error handling to ensure users receive clear feedback on empty response failures. - Included unit tests to validate the new behavior and ensure robustness.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
Pull request overview
Introduces Autopilot Supervisor v2 as a new rustfox::supervisor library module that sits above the existing Agent, providing a persisted task/job state machine with pluggable execution backends, verification, artifacts, resume/pause, workspace preparation for code tasks, and risk-threshold gating—while keeping existing Telegram bot behavior unchanged.
Changes:
- Add the full supervisor pipeline (task model, store, state machine, planner/orchestrator, verification/reporter, artifacts + redaction, workspace manager) plus multiple concrete backends.
- Extend skills to support
supervisor:frontmatter and ship five defaultsup-*workflow skill packs. - Add LLM prompt preparation/compaction and empty-response retry recovery; wire new config knobs and production supervisor startup bootstrap.
Reviewed changes
Copilot reviewed 64 out of 67 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/supervisor_workspace.rs | Verifies workspace artifact creation for rigorous code tasks |
| tests/supervisor_thresholds_wired.rs | Ensures risk thresholds from config affect submit outcomes |
| tests/supervisor_skill_packs.rs | Asserts five sup-* skill packs ship and are workflow-enabled |
| tests/supervisor_resume.rs | Tests pause/resume persistence and completion behavior |
| tests/supervisor_intake_classifier.rs | Confirms submit persists task + intake/classification/policy artifacts |
| tests/supervisor_e2e_fast_mode.rs | E2E fast-mode execution and final state assertions |
| tests/supervisor_dod_smoke.rs | DoD smoke coverage across multiple workflow types and gates |
| src/supervisor/workspace.rs | Git branch/worktree workspace creation utilities |
| src/supervisor/workflow.rs | Execution-mode stage templates (fast/standard/rigorous) |
| src/supervisor/verification.rs | Evidence-based verification gate for job outputs |
| src/supervisor/task.rs | Task model enums/struct + serialization |
| src/supervisor/store.rs | SQLite CRUD for tasks/jobs/transitions + resumable discovery |
| src/supervisor/state.rs | Supervisor state-machine transition allowlist |
| src/supervisor/reporter.rs | Human-readable report rendering from job outputs |
| src/supervisor/redact.rs | Secret-redaction regex filter for persisted artifacts |
| src/supervisor/policy.rs | Risk-threshold-driven policy decisions (auto/clarify/approval) |
| src/supervisor/planner.rs | Minimal planner producing jobs + parallel group metadata |
| src/supervisor/orchestrator.rs | Plan execution with parallel groups, fallbacks, subjob draining |
| src/supervisor/mod.rs | Supervisor facade API (submit/execute_now/pause/resume/state) |
| src/supervisor/job.rs | Job model + output/evidence contract |
| src/supervisor/intake.rs | Request normalization into initial Task struct |
| src/supervisor/classifier.rs | Heuristic + skill-aware classification and capability selection |
| src/supervisor/backend/shell.rs | Sandboxed shell backend (with basic validation) |
| src/supervisor/backend/script.rs | Script backend via shared CLI runner |
| src/supervisor/backend/reasoning.rs | Reasoning backend wrapping the existing Agent |
| src/supervisor/backend/mod.rs | Backend trait, registry, run context, CLI runner helper |
| src/supervisor/backend/mcp.rs | MCP tool-calling backend |
| src/supervisor/backend/codex.rs | Codex CLI backend wrapper |
| src/supervisor/backend/claude_code.rs | Claude Code CLI backend wrapper |
| src/supervisor/artifact.rs | Artifact persistence + DB indexing + redaction integration |
| src/skills/mod.rs | Add supervisor workflow/capability fields to Skill |
| src/skills/loader.rs | Parse supervisor: frontmatter block (workflow + required caps) |
| src/platform/telegram.rs | Add generic slash-command parser + tests |
| src/memory/summarizer.rs | Remove unused test import |
| src/memory/mod.rs | Add supervisor SQLite tables + migration test |
| src/memory/conversations.rs | Remove unused test import |
| src/mcp.rs | Add Default for McpManager |
| src/main.rs | Convert to bin+lib usage and bootstrap supervisor registry at startup |
| src/llm.rs | Add ChatCompletion + empty-response detection + new chat APIs |
| src/lib.rs | Expose crate modules for library usage |
| src/config.rs | Add [supervisor] + risk thresholds; add agent empty-response retry config |
| src/agent.rs | Wire prompt preparation/compaction and empty-response retry loop |
| src/agent_prompt.rs | New prompt prep/compaction implementation and tests |
| skills/sup-writing/SKILL.md | Add default writing supervisor workflow pack |
| skills/sup-research/SKILL.md | Add default research supervisor workflow pack |
| skills/sup-ops/SKILL.md | Add default ops supervisor workflow pack |
| skills/sup-general/SKILL.md | Add default general supervisor workflow pack |
| skills/sup-coding/SKILL.md | Add default coding supervisor workflow pack |
| docs/plans/2026-04-30-autopilot-supervisor-design.md | Add v2 supervisor design spec documentation |
| config.example.toml | Document new agent retry knob and optional local base_url |
| CLAUDE.md | Document supervisor architecture, lifecycle, config, and DB tables |
| Cargo.toml | Add regex dependency for redaction |
| Cargo.lock | Lockfile updates for regex and deps |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| }) | ||
| .collect(); | ||
| let branch = format!("supervisor/{safe_slug}-{}", &task_id[..8]); | ||
|
|
||
| if self.use_worktree { |
| .join(format!("{repo_name}-worktree-{}", &task_id[..8])); | ||
| run( | ||
| &self.repo, | ||
| &["worktree", "add", "-b", &branch, path.to_str().unwrap()], | ||
| ) |
| let safe_content = crate::supervisor::redact::redact(content); | ||
| let task_dir = self.root.join(task_id); | ||
| tokio::fs::create_dir_all(&task_dir) | ||
| .await | ||
| .with_context(|| format!("create artifact dir {}", task_dir.display()))?; | ||
| let path = task_dir.join(filename); | ||
| tokio::fs::write(&path, &safe_content) | ||
| .await | ||
| .with_context(|| format!("write artifact {}", path.display()))?; |
| let mut last_err: Option<String> = None; | ||
| for name in &backends { | ||
| let backend = reg | ||
| .select_by_name(name) | ||
| .or_else(|| reg.select_for(std::slice::from_ref(name))) | ||
| .or_else(|| reg.select_by_name("reasoning")); | ||
| let Some(backend) = backend else { | ||
| last_err = Some(format!("backend not found: {name}")); | ||
| continue; | ||
| }; | ||
| match backend.run(&mut job, ctx).await { | ||
| Ok(out) if matches!(out.status, JobStatus::Succeeded) => { | ||
| store | ||
| .update_job_status(&job.id, JobStatus::Succeeded, Some(&out.summary), None) | ||
| .await?; | ||
| return Ok(JobOutcome::Succeeded); | ||
| } |
| let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); | ||
| let ctx = RunContext::with_subjob_channel(tx); | ||
| let parent_id = job.id.clone(); | ||
| let outcome = Self::execute_one_job(reg, store, fallbacks, job, &ctx).await?; | ||
| // Dropping `ctx` closes the sender so try_recv won't block forever | ||
| // even if a backend cloned the channel internally. | ||
| drop(ctx); | ||
| while let Ok(mut subjob) = rx.try_recv() { | ||
| subjob.parent_job_id = Some(parent_id.clone()); | ||
| let _ = | ||
| Self::execute_one_job(reg, store, fallbacks, subjob, &RunContext::new()).await?; | ||
| } |
| pub async fn execute_now(&self, task_id: &str) -> anyhow::Result<String> { | ||
| let task = self | ||
| .store | ||
| .get(task_id) | ||
| .await? | ||
| .ok_or_else(|| anyhow::anyhow!("task not found"))?; | ||
|
|
||
| // PLAN | ||
| self.store | ||
| .record_transition( | ||
| task_id, | ||
| TaskStatus::Route, | ||
| TaskStatus::Plan, | ||
| "supervisor", | ||
| None, | ||
| ) | ||
| .await?; |
| PolicyDecision::RequireApproval => SubmitOutcome::NeedsApproval { | ||
| task_id: task.id, | ||
| reason: "high-risk task".into(), | ||
| }, | ||
| other => SubmitOutcome::NeedsApproval { | ||
| task_id: task.id, | ||
| reason: format!("{other:?}"), | ||
| }, |
- workspace.rs: safe truncation of task_id (no panic on short IDs) - workspace.rs: pass Path/OsStr directly to git commands (no unwrap) - artifact.rs: validate task_id and filename against path traversal - orchestrator.rs: update job.backend to actual executing backend - orchestrator.rs: use recv().await for reliable subjob drain - mod.rs: use task's actual status for execute_now() transition - mod.rs/policy.rs: accurate reason for RequireApproval decisions - state.rs: allow Execute->Plan and Paused->Plan transitions for resume
|
@copilot please fix Run cargo fmt --all -- --check
Diff in /home/runner/work/RustFox/RustFox/src/supervisor/workspace.rs:73:
Error: Process completed with exit code 1. |
Fixed in the latest commit — applied |
|
@copilot can you help me to fix the conflicts of this pull request? |
I checked and there are no actual merge conflicts — the branch is fully up-to-date with |


Status — Ready to Merge ✅
All 8 milestones complete. 275 tests passing (246 lib + 29 integration).
cargo clippy --all-targets -- -D warningsclean.cargo fmt --all -- --checkclean.cargo buildclean.Summary
Evolves RustFox from a single-loop AI assistant into a generic autonomous task supervisor with task-first state machine, pluggable backends (Claude Code CLI, Codex CLI, shell, MCP, the existing in-process Agent), policy-driven autonomy, evidence-based verification, and resumable persisted state.
The new
src/supervisor/module sits above the existingAgent. Existing Telegram bot behavior is unchanged — the supervisor pipeline is exposed via theSupervisor::*API; full/superviseTelegram dispatcher routing is a deferred follow-up.Milestone breakdown
SupervisorConfig, 4 SQLite tables (sup_tasks,sup_jobs,sup_transitions,sup_artifacts)Task/Job/state machine/TaskStore/IntakeRouter/HeuristicClassifier/PolicyEngine/ArtifactManager/Supervisor::submitend-to-end; crate becomes hybrid bin+libBackendtrait (with spec §10 methods) + capabilityRegistry+ 6 concrete backends (Reasoning, Shell, MCP, ClaudeCode, Codex, Script). CLI backends enforce per-job timeout viakill_on_drop(true).WorkflowTemplate/Planner/Orchestrator/VerificationEngine/Reporter+Supervisor::execute_nowend-to-end +parse_commandhelper for/superviseWorkspaceManager(git branch + optional worktree) +PREPARE_WORKSPACEstage for code tasks; classification persisted acrosssubmit/execute_nowsupervisor:block + 5 default workflow skill packs (sup-coding,sup-research,sup-writing,sup-ops,sup-general) +SkillAwareClassifierRunContextRiskThresholdsConfig), pause/resume + startup discovery, supervisor command parser, secret redaction in artifact write pathReview-state audit;CLAUDE.mddocuments the new architectureFinal review fixes applied
Execute → Review → Verifyso the audit trail matchesWorkflowTemplate(was alwaysExecute → Verify).parallel_groups=[[0,3]]with serial jobs at indices 1, 2).Supervisorinmain.rsnow boots withReasoningBackend::from_agentandShellBackendregistered (was empty registry).Known accepted debt (documented at call sites, scoped for follow-up PRs)
transition_allowedtable defined but not enforced insiderecord_transition(M3 review note).jobs_for_taskreconstructsresult.evidencelossily (synth oneOutputValidatedstub) — full evidence persistence requires a schema change./supervise,/tasks,/resume,/cancel,/approve,/clarifyparser is wired and tested but the Telegram message dispatcher does not yet route to handlers (programmatic API only).ShellBackend::validateuses naive substring checks (TODO comment names the M2.5 hardening targets).LlmBackedClassifier::inner_llmis#[allow(dead_code)]— the LLM-augmented classification path is deferred per M1.7.What's new from a runtime perspective
skills/sup-*/SKILL.md.[supervisor](default_autonomy_mode, artifacts_dir) +[supervisor.risk](require_approval_for_low/medium, auto_execute_only_low). All defaults preserve existing behavior.Verification
cargo test: 275 passed, 0 failedcargo clippy --all-targets -- -D warnings: cleancargo fmt --all -- --check: cleancargo build: cleanProcess
Implemented via subagent-driven development: per milestone, dispatched a fresh implementer subagent with full task context, then ran independent spec-compliance + code-quality reviewer subagents. Every milestone had its review issues addressed before moving to the next. Final integration review caught three additional issues (I-1, I-2, I-3), all fixed.
35+ commits, one logical change per commit.