Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit 736069f

Browse files
author
Jobdori
committed
feat(worker_boot): classify session completion failures (P2.13)
Add WorkerFailureKind::Provider variant and observe_completion() method to classify degraded session completions as structured failures. - Detects finish='unknown' + zero tokens as provider failure - Detects finish='error' as provider failure - Normal completions transition to Finished state - 2 new tests verify classification behavior This closes the gap where sessions complete but produce no output, and the failure mode wasn't machine-readable for recovery policy. ROADMAP P2.13 backlog item added.
1 parent 69b9232 commit 736069f

3 files changed

Lines changed: 122 additions & 18 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
287287
10. MCP structured degraded-startup reporting
288288
11. Structured task packet format
289289
12. Lane board / machine-readable status API
290+
13. **Session completion failure classification** — sessions that complete with `finish: "unknown"` or zero output (provider errors, context exhaustion, etc.) are not classified by `WorkerFailureKind`; worker boot state machine cannot surface these as structured failures for recovery policy
290291

291292
**P3 — Swarm efficiency**
292293
13. Swarm branch-lock protocol — detect same-module/same-branch collision before parallel workers drift into duplicate implementation

rust/crates/runtime/src/worker_boot.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub enum WorkerFailureKind {
5252
TrustGate,
5353
PromptDelivery,
5454
Protocol,
55+
Provider,
5556
}
5657

5758
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -390,6 +391,60 @@ impl WorkerRegistry {
390391
);
391392
Ok(worker.clone())
392393
}
394+
395+
/// Classify session completion and transition worker to appropriate terminal state.
396+
/// Detects degraded completions (finish="unknown" with zero tokens) as provider failures.
397+
pub fn observe_completion(
398+
&self,
399+
worker_id: &str,
400+
finish_reason: &str,
401+
tokens_output: u64,
402+
) -> Result<Worker, String> {
403+
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
404+
let worker = inner
405+
.workers
406+
.get_mut(worker_id)
407+
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
408+
409+
// Detect degraded completion: finish=unknown with zero output is provider failure
410+
let is_provider_failure =
411+
(finish_reason == "unknown" && tokens_output == 0) || finish_reason == "error";
412+
413+
if is_provider_failure {
414+
let message = if finish_reason == "unknown" && tokens_output == 0 {
415+
"session completed with finish='unknown' and zero output — provider degraded or context exhausted".to_string()
416+
} else {
417+
format!("session failed with finish='{finish_reason}' — provider error")
418+
};
419+
420+
worker.last_error = Some(WorkerFailure {
421+
kind: WorkerFailureKind::Provider,
422+
message,
423+
created_at: now_secs(),
424+
});
425+
worker.status = WorkerStatus::Failed;
426+
push_event(
427+
worker,
428+
WorkerEventKind::Failed,
429+
WorkerStatus::Failed,
430+
Some("provider failure classified".to_string()),
431+
);
432+
} else {
433+
// Normal completion
434+
worker.status = WorkerStatus::Finished;
435+
worker.last_error = None;
436+
push_event(
437+
worker,
438+
WorkerEventKind::Finished,
439+
WorkerStatus::Finished,
440+
Some(format!(
441+
"session completed: finish='{finish_reason}', tokens={tokens_output}"
442+
)),
443+
);
444+
}
445+
446+
Ok(worker.clone())
447+
}
393448
}
394449

395450
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -729,4 +784,54 @@ mod tests {
729784
.iter()
730785
.any(|event| event.kind == WorkerEventKind::Finished));
731786
}
787+
788+
#[test]
789+
fn observe_completion_classifies_provider_failure_on_unknown_finish_zero_tokens() {
790+
let registry = WorkerRegistry::new();
791+
let worker = registry.create("/tmp/repo-f", &[], true);
792+
registry
793+
.observe(&worker.worker_id, "Ready for input\n>")
794+
.expect("ready observe should succeed");
795+
registry
796+
.send_prompt(&worker.worker_id, Some("Run tests"))
797+
.expect("prompt send should succeed");
798+
799+
// Simulate degraded completion: finish="unknown", zero output
800+
let failed = registry
801+
.observe_completion(&worker.worker_id, "unknown", 0)
802+
.expect("completion observe should succeed");
803+
804+
assert_eq!(failed.status, WorkerStatus::Failed);
805+
let error = failed.last_error.expect("provider error should exist");
806+
assert_eq!(error.kind, WorkerFailureKind::Provider);
807+
assert!(error.message.contains("provider degraded"));
808+
assert!(failed
809+
.events
810+
.iter()
811+
.any(|event| event.kind == WorkerEventKind::Failed));
812+
}
813+
814+
#[test]
815+
fn observe_completion_accepts_normal_finish_with_tokens() {
816+
let registry = WorkerRegistry::new();
817+
let worker = registry.create("/tmp/repo-g", &[], true);
818+
registry
819+
.observe(&worker.worker_id, "Ready for input\n>")
820+
.expect("ready observe should succeed");
821+
registry
822+
.send_prompt(&worker.worker_id, Some("Run tests"))
823+
.expect("prompt send should succeed");
824+
825+
// Normal completion with output
826+
let finished = registry
827+
.observe_completion(&worker.worker_id, "stop", 150)
828+
.expect("completion observe should succeed");
829+
830+
assert_eq!(finished.status, WorkerStatus::Finished);
831+
assert!(finished.last_error.is_none());
832+
assert!(finished
833+
.events
834+
.iter()
835+
.any(|event| event.kind == WorkerEventKind::Finished));
836+
}
732837
}

rust/crates/runtime/tests/integration_tests.rs

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
66
use std::time::Duration;
77

8+
use runtime::green_contract::{GreenContract, GreenContractOutcome, GreenLevel};
89
use runtime::{
9-
apply_policy, BranchFreshness, DiffScope, LaneBlocker,
10-
LaneContext, PolicyAction, PolicyCondition, PolicyEngine, PolicyRule,
11-
ReconcileReason, ReviewStatus, StaleBranchAction, StaleBranchPolicy,
10+
apply_policy, BranchFreshness, DiffScope, LaneBlocker, LaneContext, PolicyAction,
11+
PolicyCondition, PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus, StaleBranchAction,
12+
StaleBranchPolicy,
1213
};
13-
use runtime::green_contract::{GreenLevel, GreenContract, GreenContractOutcome};
1414

1515
/// stale_branch + policy_engine integration:
1616
/// When a branch is detected stale, does it correctly flow through
@@ -211,7 +211,7 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
211211
// when: build context and evaluate policy
212212
let context = LaneContext::new(
213213
"lane-9411",
214-
3, // Workspace green
214+
3, // Workspace green
215215
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
216216
LaneBlocker::None,
217217
ReviewStatus::Approved,
@@ -260,26 +260,24 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
260260
fn fresh_approved_lane_gets_merge_action() {
261261
let context = LaneContext::new(
262262
"fresh-approved-lane",
263-
3, // Workspace green
263+
3, // Workspace green
264264
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
265265
LaneBlocker::None,
266266
ReviewStatus::Approved,
267267
DiffScope::Scoped,
268268
false,
269269
);
270270

271-
let engine = PolicyEngine::new(vec![
272-
PolicyRule::new(
273-
"merge-if-green-approved-not-stale",
274-
PolicyCondition::And(vec![
275-
PolicyCondition::GreenAt { level: 3 },
276-
PolicyCondition::ReviewPassed,
277-
// NOT PolicyCondition::StaleBranch — fresh lanes bypass this
278-
]),
279-
PolicyAction::MergeToDev,
280-
5,
281-
),
282-
]);
271+
let engine = PolicyEngine::new(vec![PolicyRule::new(
272+
"merge-if-green-approved-not-stale",
273+
PolicyCondition::And(vec![
274+
PolicyCondition::GreenAt { level: 3 },
275+
PolicyCondition::ReviewPassed,
276+
// NOT PolicyCondition::StaleBranch — fresh lanes bypass this
277+
]),
278+
PolicyAction::MergeToDev,
279+
5,
280+
)]);
283281

284282
let actions = engine.evaluate(&context);
285283
assert_eq!(actions, vec![PolicyAction::MergeToDev]);

0 commit comments

Comments
 (0)