feat(v0.5.3): summary fields for end_reason / ownership / adoption

This commit is contained in:
2026-05-08 13:53:02 -04:00
parent 7697ec0507
commit 8b82af1598
2 changed files with 80 additions and 0 deletions
+19
View File
@@ -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
+61
View File
@@ -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)
}
}
}