diff --git a/internal/session/summary.go b/internal/session/summary.go index ef95ed6..5656573 100644 --- a/internal/session/summary.go +++ b/internal/session/summary.go @@ -26,6 +26,25 @@ type SessionSummary struct { FilesModified []string `json:"files_modified"` FilesDeleted []string `json:"files_deleted"` NotesUpdated bool `json:"notes_updated"` + + // EndReason is "child_exited" for direct mode, "tmux_session_ended" for + // persistent mode (both owner-create and adopted reattach). Optional; + // pre-v0.5.3 summaries omit it. + EndReason string `json:"end_reason,omitempty"` + + // DetectedVia distinguishes the mechanism that observed session end: + // "child_exit" for direct mode, "polling" for persistent mode. + DetectedVia string `json:"detected_via,omitempty"` + + // SessionOwnership is "created" if this ctask process originated the + // session (owner-create) or "adopted" if it took over an orphaned + // persistent session. Omitted in direct mode. + SessionOwnership string `json:"session_ownership,omitempty"` + + // AdoptedFromOrphanAt records the moment adoption took place. Set only + // for adopted reattach. + AdoptedFromOrphanAt *time.Time `json:"adopted_from_orphan_at,omitempty"` + // EndManifest captures the workspace file list at end-of-session so the // next session can diff current state against it (Layer 3). This field // is ctask-internal; it is not part of the public summary format shown diff --git a/internal/session/summary_test.go b/internal/session/summary_test.go index 82efe7c..4e8f1c6 100644 --- a/internal/session/summary_test.go +++ b/internal/session/summary_test.go @@ -169,3 +169,64 @@ func TestFormatLaunchContextRendersChanged(t *testing.T) { } } } + +func TestSummaryNewFieldsRoundTrip(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, ".ctask", "last-session-summary.json") + + adoptedAt := time.Now().UTC().Truncate(time.Second) + s := &SessionSummary{ + SessionID: "host-1-20260508140000", + Hostname: "host", + Agent: "claude", + Mode: "local", + StartedAt: adoptedAt, + EndedAt: adoptedAt.Add(10 * time.Minute), + DurationSeconds: 600, + EndReason: "tmux_session_ended", + DetectedVia: "polling", + SessionOwnership: "adopted", + AdoptedFromOrphanAt: &adoptedAt, + } + if err := WriteSummary(path, s); err != nil { + t.Fatalf("WriteSummary: %v", err) + } + got, err := ReadSummary(path) + if err != nil { + t.Fatalf("ReadSummary: %v", err) + } + if got.EndReason != "tmux_session_ended" { + t.Errorf("EndReason: got %q", got.EndReason) + } + if got.DetectedVia != "polling" { + t.Errorf("DetectedVia: got %q", got.DetectedVia) + } + if got.SessionOwnership != "adopted" { + t.Errorf("SessionOwnership: got %q", got.SessionOwnership) + } + if got.AdoptedFromOrphanAt == nil || !got.AdoptedFromOrphanAt.Equal(adoptedAt) { + t.Errorf("AdoptedFromOrphanAt: got %v, want %v", got.AdoptedFromOrphanAt, adoptedAt) + } +} + +func TestSummaryNewFieldsOmittedWhenEmpty(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, ".ctask", "last-session-summary.json") + + s := &SessionSummary{ + SessionID: "x", Hostname: "h", Agent: "claude", Mode: "local", + StartedAt: time.Now().UTC(), EndedAt: time.Now().UTC(), + } + if err := WriteSummary(path, s); err != nil { + t.Fatalf("WriteSummary: %v", err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + for _, key := range []string{"end_reason", "detected_via", "session_ownership", "adopted_from_orphan_at"} { + if strings.Contains(string(body), key) { + t.Errorf("expected %q to be omitted; body:\n%s", key, body) + } + } +}