Files
pad/internal/cli/session_worker.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

117 lines
3.7 KiB
Go

package cli
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
)
// ResolveWorkerID decides what goes in the X-Pad-Worker header (TASK-3613):
// the opaque id this session holds item leases under, so two sessions that
// share an agent name and an account do not share a lease.
//
// 1. $PAD_WORKER_ID when it is well formed: an explicit choice, for resuming
// after a restart or for a harness pad cannot register.
// 2. This session's registry record (`pad session register`), minting and
// storing an id into a record written before worker ids existed.
// 3. Otherwise "": a name-only caller, today's behaviour. No other
// persistence is invented (a per-cwd file would be shared by every
// unregistered session in that checkout, which is the collision again).
func ResolveWorkerID() string {
if v := strings.TrimSpace(os.Getenv("PAD_WORKER_ID")); models.ValidWorkerID(v) {
return v
}
return sessionWorkerID()
}
// MintWorkerID returns a fresh worker id: "wk_" + 128 random bits in hex.
func MintWorkerID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("mint lease worker id: %w", err)
}
return "wk_" + hex.EncodeToString(b[:]), nil
}
// sessionWorkerID returns this session's worker id from its registry record,
// minting one into a record that predates worker ids. "" when the session is
// not registered or the record cannot be updated.
func sessionWorkerID() string {
path, reg, ok := thisSessionRegistration()
if !ok {
return ""
}
if models.ValidWorkerID(reg.WorkerID) {
return reg.WorkerID
}
unlock, err := lockSessionsDir(filepath.Dir(path))
if err != nil {
return ""
}
defer unlock()
// Re-read under the lock: another `pad` process in this session may
// have minted one since our first read.
if id := keptWorkerID(path, reg.SessionOwner); id != "" {
return id
}
id, err := MintWorkerID()
if err != nil {
return ""
}
reg.WorkerID = id
data, err := json.MarshalIndent(reg, "", " ")
if err != nil {
return ""
}
if err := atomicWriteFile(path, data, 0600); err != nil {
return ""
}
return id
}
// keptWorkerID is the worker id in the record at path when that record
// belongs to the same process as owner (pid AND start token), else "". The
// start token is what tells a /clear in one process from a new process
// that reuses the pid; see below for platforms that record none.
func keptWorkerID(path string, owner SessionOwner) string {
data, err := readRegistryBytes(path)
if err != nil {
return ""
}
var prev SessionRegistration
if json.Unmarshal(data, &prev) != nil || !registrationWellFormed(&prev) {
return ""
}
// Same pid AND same start token is the same process. Where the platform
// records no start token on either side, the pid alone has to do: a
// /clear must keep its leases (lead ruling), and a reused pid inheriting
// a dead session's id is the lesser risk, since its leases expire within
// their TTL. A token on one side only is a different process.
if prev.PID != owner.PID || prev.ProcStart != owner.ProcStart {
return ""
}
if !models.ValidWorkerID(prev.WorkerID) {
return ""
}
return prev.WorkerID
}
// SetWorkerID makes this client send id as X-Pad-Worker instead of the one
// ResolveWorkerID would pick: the `--worker-id` flag, which is also how a
// local-stdio MCP call's `worker_id` param arrives. It must be called before
// the first request. A malformed id is refused here, not sent.
func (c *Client) SetWorkerID(id string) error {
if !models.ValidWorkerID(id) {
return fmt.Errorf(`--worker-id must be "wk_" followed by 32 lowercase hex characters, got %q`, id)
}
c.workerOnce.Do(func() {})
c.workerID = id
return nil
}