Skip to content

Commit 99ad37e

Browse files
authored
feat(assignments): add recoverable claim leases (#86)
* feat(assignments): add recoverable claim leases * docs(assignments): clarify claim fence errors
1 parent aecf528 commit 99ad37e

28 files changed

Lines changed: 2384 additions & 136 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ alpha and not stable.
1818
- Keep tool registration/application wiring in `internal/app`.
1919
- Assignment records are coordination state; execution and launch authority stay
2020
outside core.
21+
- Portable worker claims use server-issued fencing ids. Never add claim expiry
22+
without fencing prepare/progress/completion writes, and never infer that an
23+
expired reservation means host runtime execution is dead.
2124
- Skill metadata never grants tools, writes, network, or approval bypass.
2225

2326
## Skills

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ policy, credential handling, or logged-in session boundaries. Secrets, cookies,
7575
provider credentials, and external-agent private memory are outside Cairnline's
7676
core model.
7777

78+
MCP assignment claims carry a server-issued `claim.id`. It is a concurrency
79+
fence, not a bearer credential or proof of identity. Hosts must still decide who
80+
may call mutation tools. The pre-start reservation expires after the TTL
81+
advertised by `coordination.capabilities`; expiry only makes an explicit
82+
`assignments.recover_claim` possible. Cairnline never treats a running agent as
83+
dead, cancels host execution, or requeues running work automatically.
84+
7885
Cairnline assignment metadata is not authorization. Agent hosts and
7986
orchestrators must enforce their own policy boundaries even when an assignment
8087
asks for a particular execution mode, desired agent kind, or skill id.
@@ -102,6 +109,9 @@ Implemented now:
102109
collaboration artifacts
103110
- SQLite store for durable projects, project roles, work items, assignments,
104111
skill metadata, assistant proposal records, and collaboration artifacts
112+
- expiring, renewable MCP assignment reservations with server-issued fencing
113+
ids; expired pre-start claims can be explicitly recovered without allowing a
114+
stale worker to prepare, start, or complete a later claim generation
105115
- project skill discovery from interoperable `.agents/skills`,
106116
Cairnline-native `.cairnline/skills`, Claude-compatible `.claude/skills`,
107117
Gemini-compatible `.gemini/skills`, compatibility `.hecate/skills`, and
@@ -131,6 +141,8 @@ discovery; both were checked on July 4, 2026:
131141
snapshots cover projects, skills, roles, work,
132142
assignments, artifacts, evidence, reviews, handoffs, memory entries,
133143
memory candidates, and assistant proposal records
144+
(snapshot v2 preserves claim fences and still accepts v1 imports as
145+
host-authoritative unleased historical claims)
134146
- stdio MCP server with JSON-RPC framing
135147
- MCP protocol structs carry the spec fields a richer tool surface needs: tool
136148
`outputSchema`, `_meta` passthrough on tools, resources, resource content, and
@@ -191,6 +203,8 @@ discovery; both were checked on July 4, 2026:
191203
- `assignments.create`
192204
- `assignments.update`
193205
- `assignments.claim`
206+
- `assignments.renew_claim`
207+
- `assignments.recover_claim`
194208
- `assignments.prepare`
195209
- `assignments.release`
196210
- `assignments.update_status`
@@ -231,11 +245,12 @@ discovery; both were checked on July 4, 2026:
231245
- read-only work-item closeout readiness summaries derived from assignment,
232246
evidence, review, and handoff metadata
233247
- read-only project operations briefs for attention routing across active
234-
assignments, blocked closeout, review follow-up, memory candidates, and open
235-
work
248+
assignments, expired pre-start claims, blocked closeout, review follow-up,
249+
memory candidates, and open work
236250
- read-only project activity projections grouped by active, blocked, completed,
237251
and recent assignment state; queued assignments are attention items until
238-
claimed, while claimed/running/review assignments are active
252+
claimed, unexpired claimed/running/review assignments are active, and expired
253+
pre-start claims are blocked recovery items
239254
- read-only project setup-readiness and health summaries for onboarding,
240255
context/skill gaps, and bounded operator attention
241256
- deterministic assistant proposal/apply tools with durable proposal records,

cairnline.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package cairnline
88

99
import (
1010
"context"
11+
"time"
1112

1213
"github.com/hecatehq/cairnline/internal/app"
1314
"github.com/hecatehq/cairnline/internal/core"
@@ -24,6 +25,7 @@ var (
2425

2526
type Store = core.Store
2627
type Service = core.Service
28+
type ServiceOption = core.ServiceOption
2729
type MemoryStore = core.MemoryStore
2830
type SQLiteStore = sqlitestore.Store
2931

@@ -60,6 +62,7 @@ type ReviewFollowUpReadiness = core.ReviewFollowUpReadiness
6062
type DesiredAgent = core.DesiredAgent
6163
type ExecutionRef = core.ExecutionRef
6264
type Assignment = core.Assignment
65+
type AssignmentClaimLease = core.AssignmentClaimLease
6366
type AssignmentCoordination = core.AssignmentCoordination
6467
type QueuedAssignmentUpdate = core.QueuedAssignmentUpdate
6568
type AssignmentPreparation = core.AssignmentPreparation
@@ -84,7 +87,8 @@ type MemoryCandidatePromotion = core.MemoryCandidatePromotion
8487
type Snapshot = core.Snapshot
8588

8689
const (
87-
SnapshotVersion = core.SnapshotVersion
90+
SnapshotVersion = core.SnapshotVersion
91+
DefaultAssignmentClaimLeaseTTL = core.DefaultAssignmentClaimLeaseTTL
8892

8993
WorkStatusReady = core.WorkStatusReady
9094
WorkStatusDone = core.WorkStatusDone
@@ -207,6 +211,7 @@ const (
207211
ProjectOperationKindProjectSetup = core.ProjectOperationKindProjectSetup
208212
ProjectOperationKindSkill = core.ProjectOperationKindSkill
209213
ProjectOperationKindWorkItem = core.ProjectOperationKindWorkItem
214+
ProjectOperationActionRecoverClaim = core.ProjectOperationActionRecoverClaim
210215

211216
ProjectOperationSeverityBlocked = core.ProjectOperationSeverityBlocked
212217
ProjectOperationSeverityAction = core.ProjectOperationSeverityAction
@@ -222,8 +227,15 @@ const (
222227
LaunchPacketKindAssignment = core.LaunchPacketKindAssignment
223228
)
224229

225-
func NewService(store Store) *Service {
226-
return core.NewService(store)
230+
func NewService(store Store, options ...ServiceOption) *Service {
231+
return core.NewService(store, options...)
232+
}
233+
234+
// WithAssignmentClaimLeaseTTL configures the pre-start lease used by portable
235+
// worker claims created through ClaimAssignmentWithLease and MCP. Positive
236+
// values are rounded up to whole seconds with a one-second minimum.
237+
func WithAssignmentClaimLeaseTTL(ttl time.Duration) ServiceOption {
238+
return core.WithAssignmentClaimLeaseTTL(ttl)
227239
}
228240

229241
func NewMemoryStore() *MemoryStore {

cairnline_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"path/filepath"
77
"testing"
8+
"time"
89

910
"github.com/hecatehq/cairnline"
1011
)
@@ -87,6 +88,45 @@ func TestPublicAPIEmbedsCoordinationCore(t *testing.T) {
8788
}
8889
}
8990

91+
func TestPublicAPIExposesFencedAssignmentClaims(t *testing.T) {
92+
if cairnline.ProjectOperationActionRecoverClaim != "recover_assignment_claim" {
93+
t.Fatalf("ProjectOperationActionRecoverClaim = %q, want stable public action", cairnline.ProjectOperationActionRecoverClaim)
94+
}
95+
ctx := context.Background()
96+
service := cairnline.NewService(cairnline.NewMemoryStore(), cairnline.WithAssignmentClaimLeaseTTL(90*time.Second))
97+
project, err := service.CreateProject(ctx, cairnline.Project{Name: "Portable claim"})
98+
if err != nil {
99+
t.Fatalf("CreateProject() error = %v", err)
100+
}
101+
role, err := service.CreateRole(ctx, cairnline.Role{ProjectID: project.ID, Name: "Worker"})
102+
if err != nil {
103+
t.Fatalf("CreateRole() error = %v", err)
104+
}
105+
work, err := service.CreateWorkItem(ctx, cairnline.WorkItem{ProjectID: project.ID, Title: "Fence worker"})
106+
if err != nil {
107+
t.Fatalf("CreateWorkItem() error = %v", err)
108+
}
109+
assignment, err := service.CreateAssignment(ctx, cairnline.Assignment{ProjectID: project.ID, WorkItemID: work.ID, RoleID: role.ID})
110+
if err != nil {
111+
t.Fatalf("CreateAssignment() error = %v", err)
112+
}
113+
claimed, err := service.ClaimAssignmentWithLease(ctx, project.ID, assignment.ID, "portable-worker")
114+
if err != nil {
115+
t.Fatalf("ClaimAssignmentWithLease() error = %v", err)
116+
}
117+
var claim *cairnline.AssignmentClaimLease = claimed.Claim
118+
if claim == nil || claim.ID == "" || claim.ExpiresAt.Sub(claim.AcquiredAt) != 90*time.Second {
119+
t.Fatalf("claim = %+v, want public 90-second fence", claim)
120+
}
121+
running, err := service.UpdateAssignmentStatusWithClaim(ctx, project.ID, assignment.ID, cairnline.AssignmentRunning, cairnline.ExecutionRef{}, claim.ID)
122+
if err != nil {
123+
t.Fatalf("UpdateAssignmentStatusWithClaim() error = %v", err)
124+
}
125+
if running.Claim == nil || running.Claim.ID != claim.ID || !running.Claim.ExpiresAt.IsZero() {
126+
t.Fatalf("running claim = %+v, want retained public fence", running.Claim)
127+
}
128+
}
129+
90130
func TestPublicAPIOpensSQLiteStore(t *testing.T) {
91131
ctx := context.Background()
92132
service, store, err := cairnline.NewSQLiteService(ctx, filepath.Join(t.TempDir(), "cairnline.db"))

cmd/cairnline/main_test.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,32 @@ func TestCommand_StandaloneMCPPullSmoke(t *testing.T) {
126126
if !strings.Contains(nextText, assignmentID) {
127127
t.Fatalf("assignments.next text = %q, want assignment %s", nextText, assignmentID)
128128
}
129-
claimText := request.toolText(7, "assignments.claim", map[string]any{
130-
"project_id": projectID,
131-
"assignment_id": assignmentID,
132-
"claimed_by": "standalone-smoke-agent",
129+
claimResponse := request.raw(7, "tools/call", map[string]any{
130+
"name": "assignments.claim",
131+
"arguments": map[string]any{
132+
"project_id": projectID,
133+
"assignment_id": assignmentID,
134+
"claimed_by": "standalone-smoke-agent",
135+
},
133136
})
137+
var claimResult struct {
138+
Content []struct {
139+
Text string `json:"text"`
140+
} `json:"content"`
141+
StructuredContent struct {
142+
Claim *struct {
143+
ID string `json:"id"`
144+
} `json:"claim"`
145+
} `json:"structuredContent"`
146+
}
147+
if err := json.Unmarshal(claimResponse.Result, &claimResult); err != nil {
148+
t.Fatalf("claim response did not unmarshal: %v\n%s", err, string(claimResponse.Result))
149+
}
150+
if len(claimResult.Content) == 0 || claimResult.StructuredContent.Claim == nil {
151+
t.Fatalf("claim response missing content or lease: %s", string(claimResponse.Result))
152+
}
153+
claimText := claimResult.Content[0].Text
154+
claimID := claimResult.StructuredContent.Claim.ID
134155
if !strings.Contains(claimText, "Claimed assignment "+assignmentID+" by standalone-smoke-agent") {
135156
t.Fatalf("claim text = %q, want claimed assignment", claimText)
136157
}
@@ -157,6 +178,7 @@ func TestCommand_StandaloneMCPPullSmoke(t *testing.T) {
157178
completeText := request.toolText(10, "assignments.complete", map[string]any{
158179
"project_id": projectID,
159180
"assignment_id": assignmentID,
181+
"claim_id": claimID,
160182
"status": "completed",
161183
"execution_ref": map[string]any{"run_id": "standalone-smoke-run"},
162184
})

docs/agent-host-integration.md

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,73 @@ updated, err := service.UpdateQueuedAssignment(ctx, projectID, assignment.ID,
8282
The update is compare-and-set: it preserves lifecycle/execution fields and
8383
returns `ErrConflict` if another writer changed or claimed the assignment.
8484
This prevents a stale editor from releasing a claim that may already have
85-
started execution. After a claim, use `Service.PrepareAssignment` to attach a
86-
host execution reference or context snapshot while verifying the expected
87-
claim owner. Snapshot import remains an administrative/offline workflow and is
88-
not exposed as a live whole-assignment mutation.
85+
started execution. Snapshot import remains an administrative/offline workflow
86+
and is not exposed as a live whole-assignment mutation.
87+
88+
### Portable worker claims and host authority
89+
90+
Portable MCP workers use a fenced pre-start claim rather than relying on the
91+
caller-chosen `claimed_by` label:
92+
93+
```text
94+
queued ── claim ──> claimed (lease active) ── start ──> running (fence retained)
95+
96+
└── expiry + explicit recover ──> queued
97+
```
98+
99+
1. `Service.ClaimAssignmentWithLease` returns an assignment whose `claim`
100+
contains a server-generated `id`, `acquired_at`, and `expires_at`.
101+
2. While status is `claimed`, call `Service.RenewAssignmentClaim` before the
102+
expiry when provisioning or context preparation may take longer than the
103+
configured TTL. `WithAssignmentClaimLeaseTTL` changes the default five
104+
minutes for an embedding server; `coordination.capabilities` reports the
105+
effective value. Positive custom values are rounded up to whole seconds
106+
with a one-second minimum.
107+
3. Pass the exact claim id to `PrepareAssignmentWithClaim`,
108+
`ReleaseAssignmentWithClaim`, `UpdateAssignmentStatusWithClaim`, and
109+
`CompleteAssignmentWithClaim`. Missing ids are invalid; expired or
110+
superseded ids conflict.
111+
4. When work advances out of `claimed`, Cairnline retires the reservation
112+
expiry but retains its id as a fencing generation for later worker writes.
113+
114+
Claim ids are concurrency values, not authentication credentials. They may be
115+
stored in portable snapshots and returned in assignment reads. Hosts still
116+
decide which principals may invoke these methods or their MCP tools. For
117+
content-only MCP clients, `assignments.get` and `assignments.list` include
118+
`claim_id` plus `claim_expires_at` while the reservation is active, or
119+
`claim_fence` after work starts.
120+
121+
If a pre-start claim expires, it stays `claimed` until an authorized host or
122+
operator calls `RecoverAssignmentClaim` with the exact expired id. Recovery
123+
requeues it and clears prepared execution/context references. This operation is
124+
explicit so the host can reconcile any resources it created during preparation.
125+
`projects.operations_brief` and `projects.health` expose the stable
126+
`recover_assignment_claim` action hint for this state.
127+
It is never valid for `running`, `awaiting_approval`, or `awaiting_review` work:
128+
Cairnline cannot determine that host execution is dead and never cancels or
129+
requeues it automatically.
130+
131+
The original `ClaimAssignment`, `PrepareAssignment`, `ReleaseAssignment`,
132+
`UpdateAssignmentStatus`, and `CompleteAssignment` methods remain
133+
embedding-host authority surfaces for trusted reconciliation and existing
134+
Hecate integration. They bypass worker fencing by design and must not be
135+
exposed directly as agent tools. Embedders with custom `Store`
136+
implementations must implement the new claim-lease methods before upgrading.
137+
138+
Breaking (alpha): the portable worker claim contract is now fenced.
139+
`assignments.prepare` and `assignments.release` take `claim_id` instead of
140+
`claimed_by`; `assignments.update_status` and `assignments.complete` also
141+
require the current `claim_id`. `assignments.claim` returns that id in both its
142+
text and structured result. The public `Store` interface adds the seven leased
143+
claim/renew/recover and fenced mutation methods, so custom stores must implement
144+
them. Snapshot version 2 persists claim generations; version 1 imports remain
145+
supported only as unleased host-authoritative history.
146+
147+
SQLite migration and v1 snapshot import preserve existing claimed rows as
148+
unleased host-authoritative state; they do not invent an expiry or silently
149+
make old work stealable. Before handing one of those rows to an MCP worker, a
150+
trusted embedding host must reconcile/release it and let the worker claim it
151+
again under the leased contract.
89152

90153
Handoff editors follow the same rule. Use `Service.PatchHandoff`,
91154
`Service.UpdateHandoffStatus`, or `Service.DeleteHandoff` with the exact
@@ -131,6 +194,7 @@ Hosts that want agent-neutral interoperability should start here:
131194
coordination.capabilities
132195
assignments.next
133196
assignments.claim
197+
assignments.renew_claim (only while still claimed and nearing expiry)
134198
assignments.context
135199
assignments.launch_packet
136200
evidence.record
@@ -140,15 +204,22 @@ assignments.complete
140204
1. Call `coordination.capabilities` once during setup or health checks to learn
141205
the server contract and boundaries.
142206
2. Poll `assignments.next` with the host's available kind and skill ids.
143-
3. Claim one assignment with `assignments.claim`.
207+
3. Claim one assignment with `assignments.claim` and retain the returned
208+
`structuredContent.claim.id` fencing value.
144209
4. Read `assignments.context` and/or `assignments.launch_packet`. Both include
145210
the project's enabled durable memory entries.
146-
5. Build the host-native prompt/run packet from the structured metadata.
147-
6. Record evidence as the run produces useful proof.
148-
7. Complete, fail, or cancel the assignment explicitly.
149-
150-
If the host crashes after claim, it should either resume by `execution_ref` or
151-
release the claim when it knows work will not continue.
211+
5. Renew with `assignments.renew_claim` if the assignment remains `claimed` as
212+
expiry approaches. Pass the claim id to prepare, progress, and completion
213+
mutations.
214+
6. Build the host-native prompt/run packet from the structured metadata.
215+
7. Record evidence as the run produces useful proof.
216+
8. Complete, fail, or cancel the assignment explicitly.
217+
218+
If the host crashes before work starts, another authorized host may explicitly
219+
call `assignments.recover_claim` once the reservation expires, then claim the
220+
queued assignment again. If it crashes after work starts, the executing host
221+
must reconcile or resume through its `execution_ref`; claim expiry never makes
222+
runtime work stealable.
152223

153224
## Execution Ref And Approval Signal
154225

@@ -410,11 +481,14 @@ Before exposing Cairnline tools to an agent, a host should decide:
410481

411482
- Which mutating tools the agent may call.
412483
- Whether assignments can be claimed automatically or require operator review.
484+
- Which component renews pre-start claims, how it retains the non-secret claim
485+
id, and how it stops stale workers after a conflict.
413486
- Whether evidence locators are only stored, rendered as text, or opened as
414487
links after scheme validation.
415488
- Whether local roots are readable by the host, and under what path boundary.
416489
- Whether skill metadata may trigger host-native instruction loading.
417-
- How to recover or release claimed assignments after crashes.
490+
- How to reconcile prepared host resources before recovering an expired claim,
491+
and how to handle running work separately through host supervision.
418492
- How to show operator confirmation for memory promotion and destructive
419493
project changes.
420494
- Whether to render `ui://` app views, and if so, only inside a sandboxed
@@ -428,6 +502,8 @@ A minimal useful integration does not need orchestration. It only needs:
428502
- `projects.list`
429503
- `assignments.next`
430504
- `assignments.claim`
505+
- `assignments.renew_claim` when pre-start work approaches expiry
506+
- `assignments.recover_claim` for explicit expired-claim recovery
431507
- `assignments.context`
432508
- `assignments.launch_packet`
433509
- `evidence.record`

0 commit comments

Comments
 (0)