feat(v0.4): add WriteMetaLocked wrapper backed by workspace write lock

This commit is contained in:
2026-04-21 17:04:01 -04:00
parent bc5410f722
commit d1bcd1a1ba
2 changed files with 92 additions and 0 deletions
+37
View File
@@ -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 (<wsDir>/.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
}
+55
View File
@@ -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")