From 0dbfbfed2e8ec331fbb2ce568f44b58d6dc66cb6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 6 May 2026 00:02:16 -0700 Subject: [PATCH] fix(daemon/execenv): refuse to write .gc_meta.json when issue_id is empty (#2077) A non-trivial fraction of completed task workdirs (~28% in field reports) end up with .gc_meta.json files containing issue_id: "". Empty issue_id defeats the daemon's own GC loop (gc.go:139 calls GetIssueGCCheck(meta.IssueID)) and external retention scripts that cross-reference issue status before deleting orphaned workdirs. Refuse to write the file when issueID is empty, logging a Warn so operators have a starting point for debugging the upstream race condition. Skip is preferred over a sentinel-marker file: it keeps the data invariant clean (a .gc_meta.json file always carries a valid issue_id) and matches the repo CLAUDE.md preference for not preserving dual-state behavior. WriteGCMeta now takes a *slog.Logger so it can emit the warning. The package already uses log/slog (Prepare/reuseEnv), and daemon.go:884 has taskLog in scope at the only call site. Closes #1913 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- server/internal/daemon/daemon.go | 2 +- server/internal/daemon/execenv/execenv.go | 8 +++++-- .../internal/daemon/execenv/execenv_test.go | 21 +++++++++++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 68bd10209e..e57319d8a2 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -1461,7 +1461,7 @@ func (d *Daemon) handleTask(ctx context.Context, task Task, slot int) { // can look up the issue later. Written last so that a mid-task crash // leaves the directory as an orphan (cleaned up by GCOrphanTTL). if result.EnvRoot != "" { - if err := execenv.WriteGCMeta(result.EnvRoot, task.IssueID, task.WorkspaceID); err != nil { + if err := execenv.WriteGCMeta(result.EnvRoot, task.IssueID, task.WorkspaceID, taskLog); err != nil { taskLog.Warn("write gc meta failed (non-fatal)", "error", err) } } diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 4fb0e36080..70454f3a01 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -53,7 +53,7 @@ type TaskContextForEnv struct { ProjectTitle string // human-readable project title ProjectResources []ProjectResourceForEnv // resources attached to the project ChatSessionID string // non-empty for chat tasks - AutopilotRunID string // non-empty for autopilot run_only tasks + AutopilotRunID string // non-empty for autopilot run_only tasks AutopilotID string AutopilotTitle string AutopilotDescription string @@ -214,7 +214,11 @@ type GCMeta struct { const gcMetaFile = ".gc_meta.json" // WriteGCMeta writes GC metadata into the given directory. -func WriteGCMeta(envRoot, issueID, workspaceID string) error { +func WriteGCMeta(envRoot, issueID, workspaceID string, logger *slog.Logger) error { + if issueID == "" { + logger.Warn("execenv: skipping .gc_meta.json write: issue_id is empty", "envRoot", envRoot, "workspaceID", workspaceID) + return nil + } if envRoot == "" { return nil } diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index b724fee1c9..e047db72a5 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -2,6 +2,7 @@ package execenv import ( "encoding/json" + "io" "log/slog" "os" "path/filepath" @@ -14,6 +15,10 @@ func testLogger() *slog.Logger { return slog.Default() } +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + func TestShortID(t *testing.T) { t.Parallel() tests := []struct { @@ -1916,7 +1921,7 @@ func TestWriteReadGCMeta(t *testing.T) { issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" wsID := "ws-test-001" - if err := WriteGCMeta(dir, issueID, wsID); err != nil { + if err := WriteGCMeta(dir, issueID, wsID, discardLogger()); err != nil { t.Fatalf("WriteGCMeta: %v", err) } @@ -1938,11 +1943,23 @@ func TestWriteReadGCMeta(t *testing.T) { func TestWriteGCMeta_EmptyRoot(t *testing.T) { t.Parallel() - if err := WriteGCMeta("", "issue", "ws"); err != nil { + if err := WriteGCMeta("", "issue", "ws", discardLogger()); err != nil { t.Fatalf("expected nil for empty root, got %v", err) } } +func TestWriteGCMeta_EmptyIssueID(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + if err := WriteGCMeta(dir, "", "ws", discardLogger()); err != nil { + t.Fatalf("expected nil for empty issue ID, got %v", err) + } + if _, err := os.Stat(filepath.Join(dir, gcMetaFile)); !os.IsNotExist(err) { + t.Fatalf("expected gc meta file to be absent, got err=%v", err) + } +} + func TestReadGCMeta_NoFile(t *testing.T) { t.Parallel() dir := t.TempDir()