Skip to content

Commit 4db6114

Browse files
committed
fix(assignments): make transitions atomic
1 parent 6429ce4 commit 4db6114

16 files changed

Lines changed: 1754 additions & 281 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ discovery; both were checked on July 4, 2026:
191191
- `assignments.create`
192192
- `assignments.update`
193193
- `assignments.claim`
194+
- `assignments.prepare`
194195
- `assignments.release`
195196
- `assignments.update_status`
196197
- `assignments.context`

cairnline.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ type ReviewFollowUpReadiness = core.ReviewFollowUpReadiness
6060
type DesiredAgent = core.DesiredAgent
6161
type ExecutionRef = core.ExecutionRef
6262
type Assignment = core.Assignment
63+
type AssignmentCoordination = core.AssignmentCoordination
64+
type QueuedAssignmentUpdate = core.QueuedAssignmentUpdate
65+
type AssignmentPreparation = core.AssignmentPreparation
6366
type AssignmentCompatibilityFilter = core.AssignmentCompatibilityFilter
6467
type AssignmentContext = core.AssignmentContext
6568
type AssignmentLaunchPacket = core.AssignmentLaunchPacket

docs/agent-host-integration.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,38 @@ manual
6363
The mode is a request, not authorization. A host may downgrade, ignore, or
6464
reject a mode if its policy does not allow it.
6565

66+
Embedding hosts that let operators change a queued assignment's role, root,
67+
execution mode, or desired-agent metadata should use
68+
`Service.UpdateQueuedAssignment` with the exact coordination snapshot and
69+
`updated_at` value that were read:
70+
71+
```go
72+
replacement := assignment.Coordination()
73+
replacement.RoleID = newRoleID
74+
updated, err := service.UpdateQueuedAssignment(ctx, projectID, assignment.ID,
75+
cairnline.QueuedAssignmentUpdate{
76+
Expected: assignment.Coordination(),
77+
ExpectedUpdatedAt: assignment.UpdatedAt,
78+
Replacement: replacement,
79+
})
80+
```
81+
82+
The update is compare-and-set: it preserves lifecycle/execution fields and
83+
returns `ErrConflict` if another writer changed or claimed the assignment.
84+
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.
89+
90+
Progress and completion transitions are also atomic, and terminal completion is
91+
first-writer-wins. Once an assignment is completed, failed, or cancelled, later
92+
progress or terminal writes return `ErrConflict`. Completion preserves an
93+
existing `started_at`. A direct queued completion or failure records the
94+
transition as both start and finish for compatibility; cancelling queued work
95+
records only `completed_at`, so portable readers do not report that unstarted
96+
work began.
97+
6698
## Recommended MCP Pull Flow
6799

68100
Hosts that want agent-neutral interoperability should start here:

docs/mcp-quickstart.md

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,21 @@ Replace `WORK_ITEM_ID` with the generated work item id.
109109

110110
Cairnline does not perform the launch for any of these modes.
111111

112+
#### Optional: edit the queued assignment safely
113+
114+
Read the assignment with `assignments.get`, then pass its exact coordination
115+
fields and `updated_at` back as the expected snapshot. Only the replacement
116+
fields may differ:
117+
118+
```json
119+
{"jsonrpc":"2.0","id":"4-edit","method":"tools/call","params":{"name":"assignments.update","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","expected":{"work_item_id":"WORK_ITEM_ID","role_id":"ROLE_ID","root_id":"","execution_mode":"mcp_pull","desired_agent":{"kind":"any","skill_ids":["review"]}},"expected_updated_at":"ASSIGNMENT_UPDATED_AT","replacement":{"work_item_id":"WORK_ITEM_ID","role_id":"NEW_ROLE_ID","root_id":"","execution_mode":"manual","desired_agent":{"kind":"human","skill_ids":["review"]}}}}}
120+
```
121+
122+
If the assignment was edited, claimed, or prepared after the read, the update
123+
returns a conflict. Re-read before deciding whether the new state should be
124+
changed. Breaking (alpha): `assignments.update` now uses this nested
125+
compare-and-set shape and no longer changes lifecycle or execution fields.
126+
112127
### 5. Let A Compatible Agent Find Work
113128

114129
An agent can ask for queued assignments matching its kind and skills:
@@ -144,10 +159,20 @@ host does not need a side channel to recover promoted project memory. Skill
144159
metadata and source locators are provenance hints; Cairnline does not inject
145160
`SKILL.md` bodies or fetch source locators.
146161

162+
After claiming, a host may attach its execution reference or generated context
163+
snapshot without marking work as started:
164+
165+
```json
166+
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"assignments.prepare","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","claimed_by":"local-agent-reviewer","execution_ref":{"kind":"task_run","run_id":"local-run-1"},"context_snapshot_id":"HOST_CONTEXT_SNAPSHOT_ID"}}}
167+
```
168+
169+
Preparation returns a conflict if the assignment is no longer claimed by that
170+
exact worker.
171+
147172
### 8. Mark It Running
148173

149174
```json
150-
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"assignments.update_status","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","status":"running","execution_ref":{"kind":"task_run","run_id":"local-run-1"}}}}
175+
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"assignments.update_status","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","status":"running","execution_ref":{"kind":"task_run","run_id":"local-run-1"}}}}
151176
```
152177

153178
`execution_ref` is a structured, host-neutral reference to the execution the
@@ -171,7 +196,7 @@ authoritative data.
171196
When the host pauses the execution on a human approval gate, report it:
172197

173198
```json
174-
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"assignments.update_status","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","status":"awaiting_approval","execution_ref":{"kind":"task_run","run_id":"local-run-1","pending_approvals":1}}}}
199+
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"assignments.update_status","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","status":"awaiting_approval","execution_ref":{"kind":"task_run","run_id":"local-run-1","pending_approvals":1}}}}
175200
```
176201

177202
`awaiting_approval` is a first-class assignment status so portable readers can
@@ -182,7 +207,7 @@ resolves. The approval object itself stays host-owned.
182207
### 9. Record Evidence
183208

184209
```json
185-
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"evidence.record","arguments":{"project_id":"PROJECT_ID","work_item_id":"WORK_ITEM_ID","assignment_id":"ASSIGNMENT_ID","title":"Checklist review notes","body":"Reviewed the launch checklist and found two missing owner approvals.","locator":"notes://launch-checklist-review","source_kind":"note","external_id":"launch-review-1","provider":"local","trust_label":"agent_reported"}}}
210+
{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"evidence.record","arguments":{"project_id":"PROJECT_ID","work_item_id":"WORK_ITEM_ID","assignment_id":"ASSIGNMENT_ID","title":"Checklist review notes","body":"Reviewed the launch checklist and found two missing owner approvals.","locator":"notes://launch-checklist-review","source_kind":"note","external_id":"launch-review-1","provider":"local","trust_label":"agent_reported"}}}
186211
```
187212

188213
Evidence locators are stored as metadata. Clients must validate schemes before
@@ -191,7 +216,7 @@ rendering locators as links or opening them.
191216
### 10. Complete The Assignment
192217

193218
```json
194-
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"assignments.complete","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","status":"completed","execution_ref":{"kind":"task_run","run_id":"local-run-1"}}}}
219+
{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"assignments.complete","arguments":{"project_id":"PROJECT_ID","assignment_id":"ASSIGNMENT_ID","status":"completed","execution_ref":{"kind":"task_run","run_id":"local-run-1"}}}}
195220
```
196221

197222
Use `status:"failed"` when the agent cannot complete the work. The work item
@@ -200,7 +225,7 @@ remains durable coordination state for an operator or another agent to inspect.
200225
### 11. Inspect Closeout Readiness
201226

202227
```json
203-
{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"work_items.closeout_readiness","arguments":{"project_id":"PROJECT_ID","work_item_id":"WORK_ITEM_ID"}}}
228+
{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"work_items.closeout_readiness","arguments":{"project_id":"PROJECT_ID","work_item_id":"WORK_ITEM_ID"}}}
204229
```
205230

206231
Closeout readiness is advisory. Cairnline tells the operator whether assignments,
@@ -213,13 +238,13 @@ Cairnline also exposes read-only resources for clients that prefer resource
213238
pickers over tool calls. Discover parameterized URI shapes:
214239

215240
```json
216-
{"jsonrpc":"2.0","id":12,"method":"resources/templates/list"}
241+
{"jsonrpc":"2.0","id":14,"method":"resources/templates/list"}
217242
```
218243

219244
Then read concrete resources such as the assignment launch packet:
220245

221246
```json
222-
{"jsonrpc":"2.0","id":13,"method":"resources/read","params":{"uri":"cairnline://projects/PROJECT_ID/assignments/ASSIGNMENT_ID/launch-packet"}}
247+
{"jsonrpc":"2.0","id":15,"method":"resources/read","params":{"uri":"cairnline://projects/PROJECT_ID/assignments/ASSIGNMENT_ID/launch-packet"}}
223248
```
224249

225250
## Tool Error Shape

internal/app/tools.go

Lines changed: 105 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"strings"
8+
"time"
89

910
"github.com/hecatehq/cairnline/internal/core"
1011
"github.com/hecatehq/cairnline/internal/mcp"
@@ -682,34 +683,50 @@ func RegisterTools(server *mcp.Server, service *core.Service, version string) {
682683

683684
server.RegisterTool(mcp.Tool{
684685
Name: "assignments.update",
685-
Title: "Update assignment metadata",
686-
Description: "Update assignment coordination metadata such as work item, role, root, execution mode, desired agent, status, and context refs.",
686+
Title: "Update queued assignment metadata",
687+
Description: "Compare and replace coordination metadata on a pristine queued assignment. Returns a conflict if the assignment changed or was claimed after it was read.",
687688
InputSchema: json.RawMessage(`{
688689
"type":"object",
689690
"properties":{
690691
"project_id":{"type":"string","minLength":1},
691692
"assignment_id":{"type":"string","minLength":1},
692-
"work_item_id":{"type":"string","minLength":1},
693-
"role_id":{"type":"string","minLength":1},
694-
"root_id":{"type":"string"},
695-
"execution_mode":{"type":"string","enum":["manual","mcp_pull","external_adapter","orchestrated"]},
696-
"desired_agent_kind":{"type":"string"},
697-
"skill_ids":{"type":"array","items":{"type":"string"}},
698-
"status":{"type":"string","enum":["queued","claimed","running","awaiting_approval","awaiting_review","completed","failed","cancelled"]},
699-
"execution_ref":{
693+
"expected":{
700694
"type":"object",
701695
"properties":{
702-
"kind":{"type":"string"},
703-
"task_id":{"type":"string"},
704-
"run_id":{"type":"string"},
705-
"session_id":{"type":"string"},
706-
"trace_id":{"type":"string"},
707-
"pending_approvals":{"type":"integer","minimum":0}
708-
}
696+
"work_item_id":{"type":"string","minLength":1},
697+
"role_id":{"type":"string","minLength":1},
698+
"root_id":{"type":"string"},
699+
"execution_mode":{"type":"string","enum":["manual","mcp_pull","external_adapter","orchestrated"]},
700+
"desired_agent":{
701+
"type":"object",
702+
"properties":{
703+
"kind":{"type":"string"},
704+
"skill_ids":{"type":"array","items":{"type":"string"}}
705+
}
706+
}
707+
},
708+
"required":["work_item_id","role_id","execution_mode"]
709709
},
710-
"context_snapshot_id":{"type":"string"}
710+
"expected_updated_at":{"type":"string","format":"date-time"},
711+
"replacement":{
712+
"type":"object",
713+
"properties":{
714+
"work_item_id":{"type":"string","minLength":1},
715+
"role_id":{"type":"string","minLength":1},
716+
"root_id":{"type":"string"},
717+
"execution_mode":{"type":"string","enum":["manual","mcp_pull","external_adapter","orchestrated"]},
718+
"desired_agent":{
719+
"type":"object",
720+
"properties":{
721+
"kind":{"type":"string"},
722+
"skill_ids":{"type":"array","items":{"type":"string"}}
723+
}
724+
}
725+
},
726+
"required":["work_item_id","role_id","execution_mode"]
727+
}
711728
},
712-
"required":["project_id","assignment_id","work_item_id","role_id"]
729+
"required":["project_id","assignment_id","expected","expected_updated_at","replacement"]
713730
}`),
714731
}, updateAssignment(service))
715732

@@ -728,6 +745,37 @@ func RegisterTools(server *mcp.Server, service *core.Service, version string) {
728745
}`),
729746
}, claimAssignment(service))
730747

748+
server.RegisterTool(mcp.Tool{
749+
Name: "assignments.prepare",
750+
Title: "Prepare claimed assignment",
751+
Description: "Attach host execution or context references while the assignment is still claimed by the expected worker. This does not mark work as started.",
752+
InputSchema: json.RawMessage(`{
753+
"type":"object",
754+
"properties":{
755+
"project_id":{"type":"string","minLength":1},
756+
"assignment_id":{"type":"string","minLength":1},
757+
"claimed_by":{"type":"string","minLength":1},
758+
"execution_ref":{
759+
"type":"object",
760+
"properties":{
761+
"kind":{"type":"string"},
762+
"task_id":{"type":"string"},
763+
"run_id":{"type":"string"},
764+
"session_id":{"type":"string"},
765+
"trace_id":{"type":"string"},
766+
"pending_approvals":{"type":"integer","minimum":0}
767+
}
768+
},
769+
"context_snapshot_id":{"type":"string","minLength":1}
770+
},
771+
"required":["project_id","assignment_id","claimed_by"],
772+
"anyOf":[
773+
{"required":["execution_ref"]},
774+
{"required":["context_snapshot_id"]}
775+
]
776+
}`),
777+
}, prepareAssignment(service))
778+
731779
server.RegisterTool(mcp.Tool{
732780
Name: "assignments.release",
733781
Title: "Release assignment claim",
@@ -1338,6 +1386,7 @@ func coordinationCapabilities(version string) mcp.ToolHandler {
13381386
core.AssignmentQueued,
13391387
core.AssignmentClaimed,
13401388
core.AssignmentRunning,
1389+
core.AssignmentAwaitingApproval,
13411390
core.AssignmentReview,
13421391
core.AssignmentCompleted,
13431392
core.AssignmentFailed,
@@ -2635,37 +2684,21 @@ func createAssignment(service *core.Service) mcp.ToolHandler {
26352684

26362685
func updateAssignment(service *core.Service) mcp.ToolHandler {
26372686
type args struct {
2638-
ProjectID string `json:"project_id"`
2639-
AssignmentID string `json:"assignment_id"`
2640-
WorkItemID string `json:"work_item_id"`
2641-
RoleID string `json:"role_id"`
2642-
RootID string `json:"root_id"`
2643-
ExecutionMode string `json:"execution_mode"`
2644-
DesiredAgentKind string `json:"desired_agent_kind"`
2645-
SkillIDs []string `json:"skill_ids"`
2646-
Status string `json:"status"`
2647-
ExecutionRef core.ExecutionRef `json:"execution_ref"`
2648-
ContextSnapshotID string `json:"context_snapshot_id"`
2687+
ProjectID string `json:"project_id"`
2688+
AssignmentID string `json:"assignment_id"`
2689+
Expected core.AssignmentCoordination `json:"expected"`
2690+
ExpectedUpdatedAt time.Time `json:"expected_updated_at"`
2691+
Replacement core.AssignmentCoordination `json:"replacement"`
26492692
}
26502693
return func(ctx context.Context, raw json.RawMessage) (mcp.CallToolResult, error) {
26512694
var input args
26522695
if err := json.Unmarshal(raw, &input); err != nil {
26532696
return mcp.CallToolResult{}, invalidArguments(err)
26542697
}
2655-
item, err := service.UpdateAssignment(ctx, core.Assignment{
2656-
ProjectID: input.ProjectID,
2657-
ID: input.AssignmentID,
2658-
WorkItemID: input.WorkItemID,
2659-
RoleID: input.RoleID,
2660-
RootID: input.RootID,
2661-
ExecutionMode: input.ExecutionMode,
2662-
Status: input.Status,
2663-
DesiredAgent: core.DesiredAgent{
2664-
Kind: input.DesiredAgentKind,
2665-
SkillIDs: input.SkillIDs,
2666-
},
2667-
ExecutionRef: input.ExecutionRef,
2668-
ContextSnapshotID: input.ContextSnapshotID,
2698+
item, err := service.UpdateQueuedAssignment(ctx, input.ProjectID, input.AssignmentID, core.QueuedAssignmentUpdate{
2699+
Expected: input.Expected,
2700+
ExpectedUpdatedAt: input.ExpectedUpdatedAt,
2701+
Replacement: input.Replacement,
26692702
})
26702703
if err != nil {
26712704
return mcp.CallToolResult{}, err
@@ -2705,6 +2738,34 @@ func claimAssignment(service *core.Service) mcp.ToolHandler {
27052738
}
27062739
}
27072740

2741+
func prepareAssignment(service *core.Service) mcp.ToolHandler {
2742+
type args struct {
2743+
ProjectID string `json:"project_id"`
2744+
AssignmentID string `json:"assignment_id"`
2745+
ClaimedBy string `json:"claimed_by"`
2746+
ExecutionRef core.ExecutionRef `json:"execution_ref"`
2747+
ContextSnapshotID string `json:"context_snapshot_id"`
2748+
}
2749+
return func(ctx context.Context, raw json.RawMessage) (mcp.CallToolResult, error) {
2750+
var input args
2751+
if err := json.Unmarshal(raw, &input); err != nil {
2752+
return mcp.CallToolResult{}, invalidArguments(err)
2753+
}
2754+
item, err := service.PrepareAssignment(ctx, input.ProjectID, input.AssignmentID, core.AssignmentPreparation{
2755+
ClaimedBy: input.ClaimedBy,
2756+
ExecutionRef: input.ExecutionRef,
2757+
ContextSnapshotID: input.ContextSnapshotID,
2758+
})
2759+
if err != nil {
2760+
return mcp.CallToolResult{}, err
2761+
}
2762+
return mcp.CallToolResult{
2763+
Content: mcp.TextContent(fmt.Sprintf("Prepared assignment %s", item.ID)),
2764+
StructuredContent: item,
2765+
}, nil
2766+
}
2767+
}
2768+
27082769
func releaseAssignment(service *core.Service) mcp.ToolHandler {
27092770
type args struct {
27102771
ProjectID string `json:"project_id"`

0 commit comments

Comments
 (0)