Files
pad/internal/cli/session_worker.go
xarmian f8756a0b5b feat(server,cli,plugin): agent presence — agent name, worker id and busy/idle activity (TASK-4040) (#2293)
* test: pin agent presence and worker-scoped session activity

* feat: carry agent presence and track activity by user and worker

* docs: describe worker presence and session activity states

* fix: include hook activity examples in CLI help

* fix(cli): keep session activity hooks silent on failures

* fix(server): tolerate activity read failures in session lists

* docs: place activity keys after presence registry details

* fix(plugin): wire silent Claude Code activity hooks

* docs: explain session busy-state hook limits

* fix: bound the entire session activity command

* fix: batch session activity reads across workers

* docs: describe detached activity reports and timestamp ordering

* fix: order worker activity atomically by client timestamp

* fix: detach hook activity reports with captured timestamps

* test: reproduce detached activity losing registered identity

* fix: capture activity identity before detaching the reporter

* test: isolate activity registry home on Windows

* docs: clarify activity ordering and hook failure behavior

* fix: bound activity reordering to ten seconds

* fix: replace activity records with corrupt client timestamps

* test: prove activity parent exits while child response is held

* fix(session): derive activity reorder window from report bound

* fix: replace non-string Redis session activity records

* test: cover cleared and expired session activity ordering

* test: cover older timestamp after legacy activity

* test: cover corrupt activity nanos and malformed JSON

* test: verify activity child deadline and process isolation

* docs: clarify read-only tokens cannot report hook activity

* test: strengthen session activity liveness and timing guarantees

* test: pin malformed null session activity timestamps

* test: harden session activity detach regression checks

* test: skip activity process-group check when ps is unavailable

* test: tie activity parent-exit bounds to the HTTP timeout

* test: bound activity liveness checks by the child HTTP deadline

* test: require ps for process-group checks in Go CI
2026-10-23 19:46:02 -04:00

118 lines
3.8 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. An empty id explicitly omits the header without resolving a
// replacement. A malformed non-empty id is refused here, not sent.
func (c *Client) SetWorkerID(id string) error {
if id != "" && !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
}