Files
pad/internal/cli/session_worker_test.go
T
xarmian a6f96107f4 feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.

A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.

- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
  The server checks the shape (400 invalid_worker_id) and always matches it
  together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
  next/ready foreign filter, COALESCEd so its negation is exact. A
  worker-bound lease is ours iff worker id + account match; a name-only one
  keeps the label + account rule, and a worker refreshing its own name-only
  lease binds it. A name-only caller never matches a worker-bound lease,
  even under the same label (the collision fixed). Migrations SQLite 142 /
  PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
  store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
  ?worker_id query, then the remote MCP caller. The claim answers with the
  caller's own worker_id; reads and the 409 show only the SHORT id
  ("wk_3f2a"); a name-only caller colliding under its own name is told how to
  get a worker id.
- CLI: `pad session register` mints the id into the session's registry
  record, keeps it when the same process re-registers (a /clear: same pid
  and start token) and mints a new one for a new process; older records get
  one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
  --worker-id on claim, release, project next and ready (the local-stdio MCP
  path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
  BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
  and the MCP session; an explicit `worker_id` (pad_item claim/release,
  pad_project next/ready) overrides it and is the resume path across
  connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
  itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
  the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
  docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
  renders no lease today; the TS type gains the short worker for a future
  badge.

Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 09:24:33 -04:00

172 lines
5.3 KiB
Go

package cli
import (
"encoding/json"
"os"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3613 (lead ruling): a session's lease worker id survives a /clear in
// the same process (which re-runs `pad session register`) and is new for a
// new process, including one that reuses the pid.
func TestTASK3613_WorkerIDSurvivesReRegisterBySameProcess(t *testing.T) {
registryEnv(t)
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
first, err := RegisterSession("/w", "rook")
if err != nil {
t.Fatal(err)
}
reg1, _ := readRegistration(t, first.Path)
if !models.ValidWorkerID(reg1.WorkerID) {
t.Fatalf("register did not mint a valid worker id: %q", reg1.WorkerID)
}
if reg1.ProcStart == "" {
t.Skip("this platform records no process start token, so a re-register cannot be told from a new process")
}
// A /clear: the same process registers again.
second, err := RegisterSession("/w", "rook")
if err != nil {
t.Fatal(err)
}
reg2, _ := readRegistration(t, second.Path)
if reg2.WorkerID != reg1.WorkerID {
t.Fatalf("re-register by the same process changed the worker id: %q -> %q", reg1.WorkerID, reg2.WorkerID)
}
if got := ResolveWorkerID(); got != reg1.WorkerID {
t.Fatalf("ResolveWorkerID = %q, want the registered %q", got, reg1.WorkerID)
}
}
func TestTASK3613_NewProcessGetsANewWorkerID(t *testing.T) {
registryEnv(t)
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
first, err := RegisterSession("/w", "rook")
if err != nil {
t.Fatal(err)
}
reg1, raw := readRegistration(t, first.Path)
if reg1.ProcStart == "" {
t.Skip("this platform records no process start token")
}
// The record now belongs to an EARLIER process that had this pid: same
// pid, different start token. A registration by the live process must
// not inherit its worker id.
raw["proc_start"] = "an-earlier-process"
data, err := json.Marshal(raw)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(first.Path, data, 0o600); err != nil {
t.Fatal(err)
}
second, err := RegisterSession("/w", "rook")
if err != nil {
t.Fatal(err)
}
reg2, _ := readRegistration(t, second.Path)
if !models.ValidWorkerID(reg2.WorkerID) || reg2.WorkerID == reg1.WorkerID {
t.Fatalf("a new process inherited the old worker id: %q -> %q", reg1.WorkerID, reg2.WorkerID)
}
}
// A record written before worker ids existed gets one minted on first use,
// persisted, and reused.
func TestTASK3613_LazyMintForAnOlderRecord(t *testing.T) {
registryEnv(t)
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
rec, err := RegisterSession("/w", "rook")
if err != nil {
t.Fatal(err)
}
_, raw := readRegistration(t, rec.Path)
delete(raw, "worker_id")
data, _ := json.Marshal(raw)
if err := os.WriteFile(rec.Path, data, 0o600); err != nil {
t.Fatal(err)
}
got := ResolveWorkerID()
if !models.ValidWorkerID(got) {
t.Fatalf("no worker id minted for an older record: %q", got)
}
reg, _ := readRegistration(t, rec.Path)
if reg.WorkerID != got {
t.Fatalf("the minted id was not persisted: record %q, resolved %q", reg.WorkerID, got)
}
if again := ResolveWorkerID(); again != got {
t.Fatalf("a second resolve minted again: %q -> %q", got, again)
}
}
func TestTASK3613_WorkerIDOverrideAndUnregistered(t *testing.T) {
registryEnv(t)
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
// Unregistered: name-only, and nothing is written.
if got := ResolveWorkerID(); got != "" {
t.Fatalf("an unregistered session resolved worker id %q, want none", got)
}
const explicit = "wk_0123456789abcdef0123456789abcdef"
t.Setenv("PAD_WORKER_ID", explicit)
if got := ResolveWorkerID(); got != explicit {
t.Fatalf("PAD_WORKER_ID = %q not honoured: %q", explicit, got)
}
t.Setenv("PAD_WORKER_ID", "not-a-worker-id")
if got := ResolveWorkerID(); got != "" {
t.Fatalf("a malformed PAD_WORKER_ID was used: %q", got)
}
c := &Client{}
if err := c.SetWorkerID("wk_short"); err == nil {
t.Fatal("SetWorkerID accepted a malformed id")
}
if err := c.SetWorkerID(explicit); err != nil || c.workerID != explicit {
t.Fatalf("SetWorkerID: err %v, id %q", err, c.workerID)
}
}
// keptWorkerID directly, including platforms that record no start token,
// where the TestTASK3613 registry tests above skip: with no token on either
// side the pid alone keeps the id (a /clear must keep its leases); a token
// on one side only is a different process.
func TestTASK3613_KeptWorkerIDStartTokenRules(t *testing.T) {
const id = "wk_0123456789abcdef0123456789abcdef"
path := t.TempDir() + "/1234.json"
write := func(procStart string) {
t.Helper()
reg := map[string]any{"session_pid": 1234, "pid": 1, "cwd": "/w", "registered_at": "2026-10-12T00:00:00Z", "worker_id": id, "version": 2}
if procStart != "" {
reg["proc_start"] = procStart
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
cases := []struct {
name, recorded, current string
keep bool
}{
{"same token", "100", "100", true},
{"other token (pid reuse)", "100", "200", false},
{"no token either side", "", "", true},
{"token on the record only", "100", "", false},
{"token on the owner only", "", "200", false},
}
for _, c := range cases {
write(c.recorded)
got := keptWorkerID(path, SessionOwner{PID: 1234, ProcStart: c.current})
if (got == id) != c.keep {
t.Errorf("%s: kept = %q, want keep=%v", c.name, got, c.keep)
}
}
}