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>
This commit is contained in:
Matt Van Horn
2026-05-06 00:02:16 -07:00
committed by GitHub
parent 1b3c78e4b5
commit 0dbfbfed2e
3 changed files with 26 additions and 5 deletions

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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()