1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
//! `POST /api/v1/sessions/{id}/delegations/shared-tree-dispatch` — who else is
//! already writing into this working directory, answered while claiming it
//! (#4480, made atomic by #5324).
//!
//! Why: `tm hook --pm-guard` decides, per `Agent`/`Task` dispatch, whether the
//! PM is about to put a SECOND file-mutating subagent into a working directory
//! that already has one. The daemon is the only process that knows: its
//! delegation tracker is where a dispatch's liveness is resolved from real
//! `SubagentStop` signals rather than guessed from a timer. The guard is a
//! short-lived hook process with no such state, so it has to ask.
//!
//! Why a route rather than a shared file: a second, hook-side ledger would be an
//! independent implementation of delegation tracking — the same records, the
//! same correlation keys, the same lifecycle — and the two would drift. The
//! daemon already receives every dispatch through the `matcher: "*"` PreToolUse
//! hook; this exposes what it already knows.
//!
//! Why POST and not GET (#5324): a read-only query cannot close the window it
//! opens. Asking "is anyone writing here?" and acting on the answer are two
//! steps, and two dispatches issued in ONE PM turn — the framework's own
//! documented pattern for parallel work — can both ask before either is
//! recorded, both see an empty set, and both be admitted. So the answer and the
//! record are one operation: this route claims the directory for the asking
//! dispatch in the same critical section that produced its answer. It mutates,
//! so it is a POST.
//!
//! What the claim IS: the delegation record the tracker would have written
//! anyway. The route hands the posted payload to
//! [`crate::daemon::services::delegation_tracker::observe`] — the tracker's own
//! `PreToolUse` observer — so the record is byte-identical to the one the
//! daemon's `matcher: "*"` hook produces for the same dispatch, and whichever
//! of the two hooks arrives second is a no-op (that observer is idempotent on
//! `tool_use_id`). No second kind of state, no new expiry, and nothing new to
//! clean up: the claim ends when the delegation ends.
//!
//! The caller passes its own `tool_use_id` so its own in-flight dispatch is
//! excluded — the daemon's hook and the guard's hook race on the same event, and
//! without that exclusion the very first dispatch of a session could find itself
//! and be denied.
//!
//! **Version skew fails open in both directions.** A new `tm hook` against an
//! old daemon POSTs to a path that does not exist; an old `tm hook` against a
//! new daemon GETs one that no longer does. Both get a 404, which the guard
//! reads as "nobody else is here" — the behaviour that shipped before #4480.
//!
//! It lives in its own module, merged as a sub-router, because `api.rs` is over
//! 1,100 SLOC and frozen at its line-cap budget — the same reason
//! [`super::managed_routes::reconcile`] gives.
//! Test: `shared_tree_dispatch_route_*` below.
//!
//! [`super::managed_routes::reconcile`]: crate::daemon::managed_routes::reconcile
use BTreeMap;
use PathBuf;
use Arc;
use ;
use ;
use Value;
use crateis_subagent_dispatch_tool;
use crate;
use crateHookEvent;
use crateSessionId;
use crateDaemonError;
use crateDaemonState;
/// Request body of [`shared_tree_dispatch_route`].
///
/// Why: the route both answers and records, and the recording is done by the
/// delegation tracker's own observer — so the body is simply the payload that
/// observer already consumes, built by `tm hook`'s single
/// `build_hook_payload`. Re-describing the same dispatch in a bespoke schema
/// here would be a second construction of one record, which is exactly the
/// drift the daemon-side route exists to avoid.
/// What: `payload` carries `cwd`, `tool`, `input` (with `subagent_type` and
/// `isolation`), `tool_use_id`, and `transcript_path`. `cwd` is read from the
/// payload rather than taken separately so the directory that is compared is
/// the same one that is recorded.
/// Test: `shared_tree_dispatch_route_reserves_the_tree_on_an_empty_answer`.
/// Response of [`shared_tree_dispatch_route`].
///
/// Why: the guard needs a count to decide and names to explain — a deny that
/// cannot say which agent it is protecting reads as arbitrary and gets retried.
/// What: `agents` holds one entry per live unisolated writer, deduplicated with
/// a count so two concurrent `rust-engineer`s render as one row rather than a
/// repeated name. `total` is the number of delegations, not of distinct names.
/// `claimed` reports whether this call WROTE anything, and what that write was
/// depends on which route answered. On
/// [`shared_tree_dispatch_route`] it means the directory was reserved for an
/// unisolated dispatch. On [`granted_worktree_route`] it means the granted
/// isolation was recorded — an isolated dispatch reserves nothing, so there is
/// no directory to take. The two share the field because both answer the same
/// question the caller asks of it: did the daemon act, or only look? A single
/// name for "the daemon wrote" keeps an older guard that ignores the field
/// behaving identically on both.
///
/// Since #5769 that field is no longer purely diagnostic on the granted route:
/// the guard reads it to tell "the checkout is free" from "the daemon declined
/// to record", which are otherwise the same empty answer. A daemon too old to
/// send it reads as not-written, which is the conservative direction — see
/// `pm_guard_dispatch::warn_on_unrecorded_grant`.
/// One agent name with its live unisolated delegation count.
/// The delegation sub-router (#4480).
///
/// Why: see the module doc — `api.rs` is grandfathered at a frozen line-cap
/// budget, so a new route is registered here and merged rather than appended
/// there.
/// What: one POST on a literal-suffixed path under `/sessions/{id}`.
/// Test: `shared_tree_dispatch_route_reports_live_unisolated_writers`.
/// `POST /api/v1/sessions/{id}/delegations/granted-worktree` (#5769).
///
/// Why: [`shared_tree_dispatch_route`] cannot record a granted dispatch, and
/// that is deliberate rather than an oversight — it re-derives eligibility with
/// [`shares_the_callers_tree`], which an `isolation: "worktree"` input makes
/// false, so its record closure never runs (pinned by
/// `shared_tree_dispatch_route_does_not_reserve_a_read_only_agent`). But a
/// dispatch the guard just rewrote from unisolated to isolated is exactly the
/// one whose record needs correcting: the tracker's `matcher: "*"` hook observes
/// the ORIGINAL payload and writes `isolation: None`, so every granted writer
/// stays named as a shared-checkout writer and ADR-0048 decision 10 denies
/// a `git merge` or `git rebase` there on a phantom. This route is the second
/// half the grant needs.
///
/// What: the same scan-and-claim as its sibling, with two differences. The
/// record is [`crate::daemon::services::delegation_tracker::record_granted_isolation`]
/// — an upsert that OVERWRITES `isolation` on an existing record, so the guard
/// and the tracker converge whichever arrives first. And eligibility asks
/// whether the dispatch declares an isolating mode, the exact inverse of the
/// sibling's question, because that is what a grant looks like on the wire.
///
/// It re-derives that from the payload rather than trusting the caller, for the
/// same reason the sibling does: whether a dispatch may hold a directory is this
/// daemon's policy call. A payload with no `cwd`, no `tool_use_id`, a
/// non-dispatch `tool`, or no isolating `isolation` answers the query and
/// records nothing.
/// Test: `a_grant_and_the_tracker_converge_in_either_order` covers both the
/// correct-an-existing-record and the record-arrives-first halves;
/// `a_grant_and_the_tracker_race_without_losing_the_isolation`,
/// `granted_worktree_route_records_nothing_without_isolation_or_a_tool_use_id`,
/// `record_granted_isolation_refuses_a_non_separating_mode`,
/// `granted_worktree_route_reports_a_live_writer_without_claiming`.
pub async
/// `POST /api/v1/sessions/{id}/delegations/shared-tree-dispatch` (#4480, #5324).
///
/// Why: see the module doc.
/// What: parses the session id, then hands the whole scan-and-claim to
/// [`DaemonState::claim_shared_tree_dispatch`], which holds one mutex across
/// both halves. The claim is taken only when the answer is empty AND this
/// dispatch would itself [`shares_the_callers_tree`] — the daemon re-derives
/// that from the payload rather than trusting the caller to have checked, so a
/// read-only or isolated dispatch can never occupy a directory. A malformed
/// session id is a 400; an unknown session is an EMPTY answer, not a 404 — a
/// session the daemon has no record of has no delegations, and a 404 would read
/// to the guard as an error rather than as "nobody else is here".
///
/// A payload with no `cwd` is answered empty and claims nothing: there is no
/// directory to compare against, and inventing one would be the only way this
/// route could produce a false deny.
/// Test: `shared_tree_dispatch_route_reports_live_unisolated_writers`,
/// `shared_tree_dispatch_route_excludes_the_callers_own_dispatch`,
/// `shared_tree_dispatch_route_rejects_a_malformed_session_id`,
/// `shared_tree_dispatch_route_denies_the_second_claim`,
/// `shared_tree_dispatch_route_does_not_reserve_a_read_only_agent`,
/// `shared_tree_dispatch_route_is_empty_without_a_cwd`.
pub async
/// Fold a list of live writer names into the wire response.
///
/// Why: both routes answer the same question in the same shape, and the
/// deduplication ("two `rust-engineer`s render as one row with a count") is the
/// part a second copy would drift on.
/// Read a non-empty string field from the forwarded hook payload.