mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-30 16:25:56 +00:00
mcp+cli: ToolSurfaceVersion 0.38 for claim/release; refuse sub-second lease ttl on both transports (#1405)
Follow-up to #1238. Review: two CONVE-735 2a rounds (backend: claude-agent), both CLEAN; trail on docapp HT-2860.
This commit is contained in:
@@ -45,6 +45,13 @@ Examples:
|
||||
if d <= 0 {
|
||||
return fmt.Errorf("--ttl must be positive, got %s", d)
|
||||
}
|
||||
// Whole seconds cross the wire and TTLSeconds is omitempty:
|
||||
// a positive sub-second --ttl would truncate to 0, be omitted,
|
||||
// and land the server's 15m default. Refuse it here, the same
|
||||
// floor the MCP mapper applies.
|
||||
if d < time.Second {
|
||||
return fmt.Errorf("--ttl must be at least 1s, got %s", d)
|
||||
}
|
||||
ttlSeconds = int(d.Seconds())
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,44 @@ func TestItemClaim_BadTTLFailsLocally(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A positive sub-second --ttl is refused locally rather than truncated
|
||||
// to 0 and omitted (which would silently land the server's 15m default);
|
||||
// exactly 1s is the floor and is sent as ttl_seconds:1.
|
||||
func TestItemClaim_SubSecondTTLRefused(t *testing.T) {
|
||||
srv := setupLeaseCLI(t)
|
||||
for _, bad := range []string{"500ms", "999ms", "1ns", "0s", "-5m"} {
|
||||
cmd := itemClaimCmd()
|
||||
cmd.SetArgs([]string{"TASK-5", "--ttl", bad})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var runErr error
|
||||
captureStdout(t, func() {
|
||||
runErr = cmd.Execute()
|
||||
})
|
||||
if runErr == nil {
|
||||
t.Errorf("--ttl %s must be refused", bad)
|
||||
}
|
||||
}
|
||||
if len(srv.claimBodies) != 0 {
|
||||
t.Fatalf("no claim should reach the server on a refused ttl, got %d", len(srv.claimBodies))
|
||||
}
|
||||
|
||||
cmd := itemClaimCmd()
|
||||
cmd.SetArgs([]string{"TASK-5", "--ttl", "1s"})
|
||||
cmd.SilenceUsage = true
|
||||
captureStdout(t, func() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--ttl 1s must be accepted: %v", err)
|
||||
}
|
||||
})
|
||||
if len(srv.claimBodies) != 1 {
|
||||
t.Fatalf("expected one claim, got %d", len(srv.claimBodies))
|
||||
}
|
||||
if n, _ := srv.claimBodies[0]["ttl_seconds"].(float64); int(n) != 1 {
|
||||
t.Errorf("posted ttl_seconds = %v, want 1", srv.claimBodies[0]["ttl_seconds"])
|
||||
}
|
||||
}
|
||||
|
||||
// release distinguishes a real release from the idempotent no-op — both
|
||||
// succeed (exit 0), but the words differ so a human reading sweep logs
|
||||
// can tell them apart.
|
||||
|
||||
@@ -178,7 +178,7 @@ var padItemSchemaParams = []ParamDef{
|
||||
|
||||
// ── Execution lease ── (#1221)
|
||||
{Name: "holder", Type: "string", Description: "Lease holder identity for action=claim/release. Optional — defaults to the authenticated user. Use a stable per-runner name (e.g. \"sweep-runner-2\") when several runners share one account, so a 409 names which one holds the item."},
|
||||
{Name: "ttl", Type: "string", Description: "Lease duration for action=claim, as a Go duration (e.g. \"15m\", \"1h\"). Optional — server default 15m, max 24h. A re-claim by the live holder extends the expiry from now (heartbeat); size the TTL to your polling/heartbeat cadence, not to the whole job."},
|
||||
{Name: "ttl", Type: "string", Description: "Lease duration for action=claim, as a Go duration (e.g. \"15m\", \"1h\"). Optional — server default 15m, max 24h. Whole seconds cross the wire: minimum 1s (shorter is refused), fractional seconds truncate. A re-claim by the live holder extends the expiry from now (heartbeat); size the TTL to your polling/heartbeat cadence, not to the whole job."},
|
||||
|
||||
{Name: "artifact", Type: "string", Description: "Full portable artifact text (YAML frontmatter + Markdown body). Required for: import — this is the artifact a prior `export` produced. NOT the same as `content` (which is just the item's Markdown body)."},
|
||||
|
||||
|
||||
@@ -780,6 +780,12 @@ func mapItemClaim(input map[string]any) (string, string, []byte, error) {
|
||||
if d <= 0 {
|
||||
return "", "", nil, fmt.Errorf("ttl must be positive, got %s", d)
|
||||
}
|
||||
// Whole seconds cross the wire. A positive sub-second ttl
|
||||
// would truncate to 0, which the server reads as "absent"
|
||||
// and defaults to 15m — refuse it here instead.
|
||||
if d < time.Second {
|
||||
return "", "", nil, fmt.Errorf("ttl must be at least 1s, got %s", d)
|
||||
}
|
||||
bodyFields["ttl_seconds"] = int(d.Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,13 +728,38 @@ func TestRoute_ItemClaim_BadTTLRefused(t *testing.T) {
|
||||
// A malformed or non-positive ttl is refused at the mapper, naming
|
||||
// the input — not sent as ttl_seconds:0 for the server to default
|
||||
// silently, which would turn a typo into a 15-minute lease.
|
||||
for _, bad := range []string{"banana", "-5m", "0s"} {
|
||||
// Sub-second values are the sharp case: positive, so they clear a
|
||||
// bare `<= 0` guard, but `int(d.Seconds())` truncates them to the
|
||||
// very ttl_seconds:0 the guard exists to keep off the wire.
|
||||
for _, bad := range []string{"banana", "-5m", "0s", "500ms", "999ms", "1ns"} {
|
||||
_, _, _, err := routeTable["item claim"](map[string]any{
|
||||
"workspace": "docapp", "ref": "TASK-5", "ttl": bad,
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("ttl %q must be refused", bad)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), bad) {
|
||||
t.Errorf("refusal for ttl %q must name the input; got %q", bad, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoute_ItemClaim_OneSecondTTLAccepted(t *testing.T) {
|
||||
// The control for the sub-second refusal: the smallest ttl that
|
||||
// survives truncation is sent as ttl_seconds:1, not refused.
|
||||
_, _, body, err := routeTable["item claim"](map[string]any{
|
||||
"workspace": "docapp", "ref": "TASK-5", "ttl": "1s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("1s ttl must be accepted: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("body: %v", err)
|
||||
}
|
||||
if got["ttl_seconds"] != float64(1) {
|
||||
t.Errorf("ttl_seconds = %v, want 1", got["ttl_seconds"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+27
-1
@@ -1017,6 +1017,32 @@ const CmdhelpVersion = "0.1"
|
||||
// browser tab and BUG-3000 carries the open half — so no surface
|
||||
// here states a duration.
|
||||
//
|
||||
// 0.38 — PR #1238 (b4rk13) / GitHub #1221, execution lease. Two
|
||||
// ADDITIVE actions on `pad_item`: `claim` takes an atomic lease on
|
||||
// an item (`holder`, `ttl`; 409 `lease_held` with the holder and
|
||||
// expiry in `details` when another live holder has it; a re-claim
|
||||
// by the live holder extends the expiry), and `release` drops it
|
||||
// (idempotent). Two new params, both optional, both ignored by
|
||||
// every other action. Purely additive — same disposition as 0.28,
|
||||
// which wired `remind` / `ack-reminder` the same way, and a 0.37
|
||||
// consumer that enumerates neither action is unaffected.
|
||||
//
|
||||
// Stated because the PR thread first ruled NO bump under DR-7:
|
||||
// DR-7 (tool_surface.go) is about a consumer tolerating an unknown
|
||||
// KEY in the serialized surface, not about the action enum. Adding
|
||||
// an action is the contract change that owns the bump — the entry
|
||||
// below (post-0.37) says exactly that, and 0.28 is the precedent.
|
||||
// The PR merged at 0.37; this bump follows it as its own commit so
|
||||
// the contributor's work lands as written.
|
||||
//
|
||||
// Also on this line: `ttl` shorter than one second is REFUSED at
|
||||
// the mapper. `int(d.Seconds())` truncates, so a sub-second ttl was
|
||||
// positive enough to pass the `<= 0` guard and still reach the
|
||||
// server as `ttl_seconds: 0`, which the handler reads as absent and
|
||||
// defaults to fifteen minutes — the exact silent default the guard
|
||||
// exists to prevent. A refusal at the edge of a brand-new param
|
||||
// breaks nothing that 0.37 accepted.
|
||||
//
|
||||
// 0.37 — PR #1337. Added the optional `agent` boolean to
|
||||
// `pad_item.get`. When true, both transports return a compact
|
||||
// projection that keeps the full body and work-relevant metadata
|
||||
@@ -1250,7 +1276,7 @@ const CmdhelpVersion = "0.1"
|
||||
// this surface can receive it; the entry exists so a future action does
|
||||
// not collapse it to permission_denied. When an action that can reach
|
||||
// it is added, that addition is the contract change and owns the bump.
|
||||
const ToolSurfaceVersion = "0.37"
|
||||
const ToolSurfaceVersion = "0.38"
|
||||
|
||||
// MetaVersionURI is the canonical URI of the queryable version document.
|
||||
// Lives outside the pad://workspace/{ws}/... namespace because it's a
|
||||
|
||||
Reference in New Issue
Block a user