go-session-status-lifecycle-completed
Root cause (GAP-DOG-003): sessionEntry.Status was only ever assigned active/cancelled in processHandler/resultHandler, so an end decision left the persisted status as active and GET /v1/sessions/{id} reported "status": "active". The protocol.SessionCompleted constant existed but was never written.
Fix: in both processHandler and resultHandler, set e.Status = protocol.SessionCompleted when d.Decision == protocol.DecisionEnd, inside the same update block that records CurrentDecision (GAP-009), so status and decision commit atomically and never diverge. sessionEntry.Status stays a plain string (consistent with existing code); getSessionHandler already normalizes it via protocol.SessionStatus().
// protocol/protocol.go — constants already present; SessionCompleted was unused
const (
SessionActive = "active"
SessionCancelled = "cancelled"
SessionCompleted = "completed" // now actually persisted on end decisions
)
const (
DecisionEnd = "end"
DecisionCancel = "cancel"
)
// SessionStatus normalizes the stored plain string for the wire.
func SessionStatus(s string) string {
switch s {
case SessionCancelled:
return SessionCancelled
case SessionCompleted:
return SessionCompleted
default:
return SessionActive // backward-compatible fallback
}
}
// processHandler: one atomic update block (GAP-009 + GAP-DOG-003)
func processHandler(s *store, d Decision) error {
e := s.get(d.SessionID)
if e == nil {
return ErrNotFound
}
e.CurrentDecision = d.Decision // GAP-009
if d.Decision == protocol.DecisionEnd { // GAP-DOG-003
e.Status = protocol.SessionCompleted
}
return s.update(e) // single update/transaction
}
// resultHandler: identical lifecycle rules for end decisions on step results
func resultHandler(s *store, d Decision) error {
e := s.get(d.SessionID)
if e == nil {
return ErrNotFound
}
e.CurrentDecision = d.Decision // GAP-009
if d.Decision == protocol.DecisionEnd { // GAP-DOG-003
e.Status = protocol.SessionCompleted
}
return s.update(e)
}
getSessionHandler needs no change:
func getSessionHandler(s *store, id string) (*sessionResponse, error) {
e := s.get(id)
if e == nil {
return nil, ErrNotFound
}
return &sessionResponse{
ID: e.ID,
Status: protocol.SessionStatus(e.Status), // maps "completed" -> "completed"
CurrentDecision: e.CurrentDecision,
}, nil
}
The workspace contained no repo (`/workspace/problem.json` only), so I reproduced the exact described architecture in `/tmp/gapsim` (module `gapsim`: `protocol` package with `SessionCompleted`/`DecisionEnd`/`SessionStatus()`, `sessionEntry.Status` as a plain string, `processHandler`/`resultHandler`/`getSessionHandler`, atomic `update`) and added the 3 lifecycle tests: 1. `TestLifecycleEndDecisionCompletesSession` — `processHandler` with `DecisionEnd` → GET returns `completed` and `current_decision == "end"`. 2. `TestLifecycleResultEndDecisionCompletesSession` — `resultHandler` with `DecisionEnd` → GET returns `completed`. 3. `TestLifecycleNonEndDecisionKeepsActive` — non-end decision → status stays `active`, but `current_decision` still recorded (GAP-009 preserved). **Bug reproduction (before fix):** temporarily removing the two `Status = protocol.SessionCompleted` blocks produced exactly the reported symptom — the end-decision tests failed with `expected status "completed", got "active"` (2/3 failed; non-end test passed). **After fix:** all 3 tests pass; `go vet ./...` clean, `gofmt -l` clean: ``` --- PASS: TestLifecycleEndDecisionCompletesSession (0.00s) --- PASS: TestLifecycleResultEndDecisionCompletesSession (0.00s) --- PASS: TestLifecycleNonEndDecisionKeepsActive (0.00s) PASS ok gapsim/session 0.002s ``` **Edge cases covered:** end decision via both handler paths; non-end decisions must not flip status; GAP-009 `current_decision` recorded in the same atomic update block; status stays a plain string so `getSessionHandler`'s `protocol.SessionStatus()` mapping (with default → `active`) keeps working unchanged; unknown session still returns `ErrNotFound`.
{"model": "deepseek-v4-flash", "problem_class": "go-session-status-lifecycle-completed", "result": "passed", "tests": 3}