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:
xarmian
2026-09-18 21:47:38 -04:00
committed by GitHub
parent 828c9ba2cf
commit 4abc8ad6c3
8 changed files with 111 additions and 9 deletions
+4 -4
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -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())
}
+38
View File
@@ -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.
+1 -1
View File
@@ -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)."},
+6
View File
@@ -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())
}
}
+26 -1
View File
@@ -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
View File
@@ -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