diff --git a/internal/workspace/metadata.go b/internal/workspace/metadata.go index 289c296..4195410 100644 --- a/internal/workspace/metadata.go +++ b/internal/workspace/metadata.go @@ -1,9 +1,12 @@ package workspace import ( + "fmt" "os" + "path/filepath" "time" + "github.com/warrenronsiek/ctask/internal/lockfile" "gopkg.in/yaml.v3" ) @@ -61,3 +64,37 @@ func ReadMeta(path string) (*TaskMeta, error) { } return &meta, nil } + +// MetadataLockTimeout is the maximum time WriteMetaLocked will wait to +// acquire the workspace write lock before giving up (non-fatal). +const MetadataLockTimeout = 2 * time.Second + +// MetadataStaleLockAfter is the age at which a write.lock is considered +// abandoned and gets removed. +const MetadataStaleLockAfter = 10 * time.Second + +// WriteMetaLocked writes meta to path while holding the workspace's write +// lock (/.ctask/write.lock). On lock-acquisition timeout, the write +// is SKIPPED and a warning is printed to stderr — per spec, metadata lock +// failures must never block or error out. +// +// Used by resume, open, and archive (session-lifecycle metadata writes). +// NOT used by Create (workspace does not exist yet) or by test helpers. +func WriteMetaLocked(path string, meta *TaskMeta) error { + return writeMetaLockedWithTimeout(path, meta, MetadataLockTimeout) +} + +// writeMetaLockedWithTimeout is the test-overridable form of WriteMetaLocked. +func writeMetaLockedWithTimeout(path string, meta *TaskMeta, timeout time.Duration) error { + wsDir := filepath.Dir(path) + lockPath := filepath.Join(wsDir, ".ctask", "write.lock") + + skipped, err := lockfile.WithLock(lockPath, timeout, MetadataStaleLockAfter, func() error { + return WriteMeta(path, meta) + }) + if skipped { + fmt.Fprintf(os.Stderr, "[ctask] Warning: could not acquire metadata lock, skipping write to %s\n", path) + return nil + } + return err +} diff --git a/internal/workspace/metadata_test.go b/internal/workspace/metadata_test.go index 67f1535..145979f 100644 --- a/internal/workspace/metadata_test.go +++ b/internal/workspace/metadata_test.go @@ -8,6 +8,61 @@ import ( "time" ) +func TestWriteMetaLockedHappyPath(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "1", Slug: "s", Title: "t", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + WorkspacePath: dir, + } + + if err := WriteMetaLocked(metaPath, meta); err != nil { + t.Fatalf("WriteMetaLocked: %v", err) + } + + got, err := ReadMeta(metaPath) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if got.Slug != "s" { + t.Errorf("Slug: got %q", got.Slug) + } + + lockPath := filepath.Join(dir, ".ctask", "write.lock") + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Errorf("lock should be released, got err=%v", err) + } +} + +func TestWriteMetaLockedSkipsOnBusyLock(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "task.yaml") + ctaskDir := filepath.Join(dir, ".ctask") + os.MkdirAll(ctaskDir, 0755) + lockPath := filepath.Join(ctaskDir, "write.lock") + + if err := os.WriteFile(lockPath, nil, 0644); err != nil { + t.Fatalf("plant lock: %v", err) + } + now := time.Now() + os.Chtimes(lockPath, now, now) + + meta := &TaskMeta{Slug: "s"} + err := writeMetaLockedWithTimeout(metaPath, meta, 200*time.Millisecond) + if err != nil { + t.Fatalf("WriteMetaLocked should not fail on busy lock: %v", err) + } + + if _, statErr := os.Stat(metaPath); !os.IsNotExist(statErr) { + t.Error("task.yaml should not exist when lock is busy") + } +} + func TestWriteAndReadMeta(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "task.yaml")