test(session): cover spawn orphan-to-errored route and restore runtime rollback (review follow-ups)
Adds an injectable OnSpawnCompleted failure to the recording LCM and two tests: - Spawn: when OnSpawnCompleted fails, the seeded record is parked terminal/errored (via OnKillRequested(KillError)) and runtime+workspace are torn down. - Restore: when OnSpawnCompleted fails post-create, the new runtime is destroyed while the workspace is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
162881d2bd
commit
4c331d907e
|
|
@ -319,12 +319,19 @@ func (noopNotifier) Notify(_ context.Context, _ ports.OrchestratorEvent) error {
|
|||
type recordingLCM struct {
|
||||
log *callLog
|
||||
inner ports.LifecycleManager
|
||||
|
||||
// onSpawnErr, when set, makes OnSpawnCompleted fail (without touching the
|
||||
// inner manager) so tests can exercise the SM's post-spawn failure paths.
|
||||
onSpawnErr error
|
||||
}
|
||||
|
||||
var _ ports.LifecycleManager = (*recordingLCM)(nil)
|
||||
|
||||
func (l *recordingLCM) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error {
|
||||
l.log.add("OnSpawnCompleted")
|
||||
if l.onSpawnErr != nil {
|
||||
return l.onSpawnErr
|
||||
}
|
||||
return l.inner.OnSpawnCompleted(ctx, id, o)
|
||||
}
|
||||
|
||||
|
|
@ -358,6 +365,7 @@ type harness struct {
|
|||
agent *fakeAgent
|
||||
workspace *fakeWorkspace
|
||||
messenger *recordingMessenger
|
||||
lcm *recordingLCM
|
||||
log *callLog
|
||||
}
|
||||
|
||||
|
|
@ -384,7 +392,7 @@ func newHarness(id domain.SessionID) *harness {
|
|||
NewID: func(ports.SpawnConfig) domain.SessionID { return id },
|
||||
})
|
||||
|
||||
return &harness{sm: sm, store: store, runtime: rt, agent: ag, workspace: ws, messenger: msg, log: log}
|
||||
return &harness{sm: sm, store: store, runtime: rt, agent: ag, workspace: ws, messenger: msg, lcm: lcm, log: log}
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]string) map[string]string {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,38 @@ func TestSpawn_RuntimeCreateFailure_RollsBack(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSpawn_OnSpawnCompletedFailure_RoutesOrphanToErrored(t *testing.T) {
|
||||
h := newHarness("sess-1")
|
||||
ctx := context.Background()
|
||||
h.lcm.onSpawnErr = errors.New("lcm boom")
|
||||
|
||||
_, err := h.sm.Spawn(ctx, spawnCfg())
|
||||
if err == nil {
|
||||
t.Fatal("spawn: want error, got nil")
|
||||
}
|
||||
|
||||
// Runtime + workspace are torn down on the failure path.
|
||||
if len(h.runtime.destroyed) != 1 {
|
||||
t.Errorf("runtime.destroyed = %d, want 1", len(h.runtime.destroyed))
|
||||
}
|
||||
if len(h.workspace.destroyed) != 1 {
|
||||
t.Errorf("workspace.destroyed = %d, want 1", len(h.workspace.destroyed))
|
||||
}
|
||||
// The record was already seeded and the store has no delete, so the orphan is
|
||||
// routed to a terminal errored state (via OnKillRequested(KillError)) rather
|
||||
// than stranded forever as "spawning".
|
||||
rec, ok, _ := h.store.Get(ctx, "sess-1")
|
||||
if !ok {
|
||||
t.Fatal("seeded record vanished; expected it parked as errored")
|
||||
}
|
||||
if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonErrorInProcess {
|
||||
t.Errorf("session substate = %+v, want terminated/error_in_process", got)
|
||||
}
|
||||
if status := domain.DeriveLegacyStatus(rec.Lifecycle); status != domain.StatusErrored {
|
||||
t.Errorf("status = %q, want errored", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKill_OrderingAndTerminalState(t *testing.T) {
|
||||
h := newHarness("sess-1")
|
||||
ctx := context.Background()
|
||||
|
|
@ -326,6 +358,38 @@ func TestRestore_MissingAgentSessionID_Errors(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) {
|
||||
h := newHarness("sess-1")
|
||||
ctx := context.Background()
|
||||
if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil {
|
||||
t.Fatalf("spawn: %v", err)
|
||||
}
|
||||
if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil {
|
||||
t.Fatalf("kill: %v", err)
|
||||
}
|
||||
if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil {
|
||||
t.Fatalf("patch metadata: %v", err)
|
||||
}
|
||||
|
||||
// Fail the post-create LCM call; capture teardown counts just before restore.
|
||||
h.lcm.onSpawnErr = errors.New("lcm boom")
|
||||
destroyedBefore := len(h.runtime.destroyed)
|
||||
wsDestroyedBefore := len(h.workspace.destroyed)
|
||||
|
||||
if _, err := h.sm.Restore(ctx, "sess-1"); err == nil {
|
||||
t.Fatal("restore: want error, got nil")
|
||||
}
|
||||
|
||||
// The runtime created during restore is torn back down so no process is
|
||||
// stranded; the workspace is left intact (it holds the agent's prior work).
|
||||
if len(h.runtime.destroyed) != destroyedBefore+1 {
|
||||
t.Errorf("runtime.destroyed grew by %d, want 1 (restore rollback)", len(h.runtime.destroyed)-destroyedBefore)
|
||||
}
|
||||
if len(h.workspace.destroyed) != wsDestroyedBefore {
|
||||
t.Errorf("workspace was destroyed on restore rollback; it must be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup_SkipsUncommittedWork(t *testing.T) {
|
||||
h := newHarness("unused")
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
Loading…
Reference in New Issue