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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
|
-- Start child agent jobs, bound how many run at once, and drain their events.
--
-- One module owns the session-wide concurrency bound and the event pump, so
-- the policy that decides *what* to start (subagents/spawn.lua) never touches
-- luv, and the callers that wait for a result (subagents/run.lua, the workflow
-- API) never touch a job.
--
-- Threading and ownership: everything here runs on panto's Lua owner thread.
-- A job's pump runs on its own thread inside the binding; it buffers events on
-- the job and writes one byte to the wake pipe handed to it here. That byte is
-- an edge trigger, never a count, so every wake drains the pipe, then drains
-- `job:next_event()` to exhaustion, then checks `job:result()`.
--
-- Waiting parks the CALLING coroutine — the tool handler's — and a poll
-- callback resumes exactly that coroutine. subagents/workflow.lua's rule that
-- a workflow callback must never run on a nested coroutine follows from this:
-- the parked thread is the one the resume goes to.
--
-- The wake pipe is therefore mandatory wherever luv is: a started job whose
-- pipe or poll could not be armed is a start failure, not a degraded job.
-- The drain-and-sleep fallback below only ever serves a luv-less host or a
-- caller with no coroutine to park (a plain script), because it cannot serve a
-- child that dispatches tools: those tool batches are posted to this very
-- thread, so a loop that never returns to uv would wait for work only it can
-- do. A visible "could not be armed" beats an unrecoverable hang.
--
-- A settled result is read and cached the moment it appears, because
-- `job:close()` frees it. Jobs are otherwise left open until close_all() ends
-- the turn, so a result can still be read after its coroutine resumed.
--
-- `job:close()` also JOINS the pump, and a pump parked in a tool batch is
-- waiting on the owner thread to come back to the uv loop — the very thread
-- every entry point here runs on. Closing an unsettled job would therefore
-- deadlock, so nothing does: only settle() closes a job, once its pump has
-- exited. close_all() asks what is still running to cancel, marks it
-- close-on-settle, and lets the wake byte that carries the settle drive the
-- close.
local ok_uv, uv = pcall(require, "luv")
local READ_CHUNK = 4096
local M = {}
-- The session-wide bound. A field, not a constant, so a caller (or a spec) can
-- lower it without reaching into the queue.
M.MAX_CONCURRENT = 4
local handle_mt = {}
handle_mt.__index = handle_mt
handle_mt.__name = "subagents.job"
-- Every handle that has not been closed, in start order; the queue is the
-- subset waiting for a slot; `running` counts the started-but-unsettled ones.
local live = {}
local queued = {}
local waiters = {}
local running = 0
local pumping = false
-- ---------------------------------------------------------------------------
-- Wake pipes
-- ---------------------------------------------------------------------------
local function open_pipe()
if not ok_uv or type(uv.pipe) ~= "function" then
return nil
end
local ok, pair = pcall(uv.pipe, { nonblock = true }, { nonblock = true })
if not ok or type(pair) ~= "table" then
return nil
end
return pair
end
-- Close the poll before the fds: the pump has already exited by the time a job
-- settles, so nothing can be mid-write when the read end goes away.
local function close_pipe(handle)
if handle.poll then
pcall(handle.poll.stop, handle.poll)
pcall(handle.poll.close, handle.poll)
handle.poll = nil
end
if handle.fds then
pcall(uv.fs_close, handle.fds.read)
pcall(uv.fs_close, handle.fds.write)
handle.fds = nil
end
end
-- Free the binding job. Safe only after the result is cached (close() frees it)
-- and the pump has exited (close() joins it), which together mean: after settle.
local function close_job(handle)
if handle.job then
pcall(handle.job.close, handle.job)
handle.job = nil
end
handle.state = "closed"
end
local function drain_pipe(handle)
if not handle.fds then
return
end
while true do
local data = uv.fs_read(handle.fds.read, READ_CHUNK, -1)
if type(data) ~= "string" or #data < READ_CHUNK then
return
end
end
end
-- ---------------------------------------------------------------------------
-- Gate, drain, settle
-- ---------------------------------------------------------------------------
local drain
local pump_queue
local wake
local function settle(handle, raw)
if handle.settled ~= nil then
return
end
local result = raw
if handle.spec.shape then
local ok, shaped = pcall(handle.spec.shape, raw)
if ok and type(shaped) == "table" then
result = shaped
elseif not ok then
result = { status = "failed", error = tostring(shaped), resumable = false }
end
end
handle.settled = result
-- A closing job still holds its slot: close_all could not join its pump, so
-- the child is still up. It gives the slot back here, once it really exits.
if handle.state == "running" or handle.state == "closing" then
running = running - 1
end
handle.state = "settled"
close_pipe(handle)
-- The turn ended while this one was still running: close_all() could not
-- join the pump then, but the pump is gone now, so the join is free.
if handle.close_on_settle then
close_job(handle)
end
pump_queue()
end
local function launch(handle)
handle.fds = open_pipe()
-- Arm the wake before the child exists, so a host that cannot give us one
-- never leaves a pump running with nothing to drain it.
if ok_uv then
if handle.fds == nil then
return false, "the subagent wake pipe could not be armed"
end
local armed, poll = pcall(uv.new_poll, handle.fds.read)
if not armed or not poll then
close_pipe(handle)
return false, "the subagent wake pipe could not be armed"
end
handle.poll = poll
end
local ok, job, err = pcall(handle.spec.build, handle.fds and handle.fds.write or nil)
if not ok then
close_pipe(handle)
return false, tostring(job)
end
if not job then
close_pipe(handle)
return false, err and tostring(err) or "the child could not be started"
end
handle.job = job
handle.state = "running"
running = running + 1
if handle.poll then
handle.poll:start("r", function()
drain(handle)
wake()
end)
end
return true
end
-- Start queued jobs while the gate has room. Reentrant: a job that settles the
-- instant it starts calls back in here, and the outer loop keeps going.
function pump_queue()
if pumping then
return
end
pumping = true
while running < M.MAX_CONCURRENT do
local handle = table.remove(queued, 1)
if handle == nil then
break
end
if handle.state == "queued" then
local ok, err = launch(handle)
if not ok then
settle(handle, { status = "failed", error = tostring(err) })
end
end
end
pumping = false
end
-- Drain one job: the wake pipe, then every buffered event, then the result.
-- Returns true when anything moved, which is what the fallback wait loop uses
-- to decide whether it is spinning.
function drain(handle)
local job = handle.job
if job == nil or handle.settled ~= nil then
return false
end
drain_pipe(handle)
local moved = false
local on_event = handle.spec.on_event
local event = job:next_event()
while event ~= nil do
moved = true
if on_event then
pcall(on_event, event)
end
event = job:next_event()
end
local raw = job:result()
if raw ~= nil then
settle(handle, raw)
return true
end
return moved
end
local function drain_all()
local moved = false
local index = 1
while index <= #live do
if drain(live[index]) then
moved = true
end
index = index + 1
end
return moved
end
-- ---------------------------------------------------------------------------
-- Handles
-- ---------------------------------------------------------------------------
function handle_mt:result()
return self.settled
end
-- A queued child settles without its build ever running: no user message is
-- appended, so nothing on disk claims a turn that never happened.
function handle_mt:cancel()
if self.settled ~= nil then
return
end
if self.state == "queued" then
for index, other in ipairs(queued) do
if other == self then
table.remove(queued, index)
break
end
end
settle(self, { status = "cancelled", error = "cancelled before the child started" })
return
end
if self.job then
pcall(self.job.request_cancel, self.job)
end
end
-- start(startspec) -> handle | nil, err
--
-- startspec = {
-- build = function(wake_fd) -> job | nil, err -- calls agent:run_async
-- id = string?
-- on_event = function(event)? -- one call per drained run_async event
-- shape = function(raw) -> result? -- maps the settled run_async result
-- onto the caller's result table
-- }
--
-- Over the bound the job is queued and `build` is not called yet.
function M.start(spec)
if type(spec) ~= "table" or type(spec.build) ~= "function" then
return nil, "jobs.start needs a build function"
end
local handle = setmetatable({
spec = spec,
id = spec.id,
state = "queued",
}, handle_mt)
live[#live + 1] = handle
if running >= M.MAX_CONCURRENT then
queued[#queued + 1] = handle
return handle
end
local ok, err = launch(handle)
if not ok then
for index, other in ipairs(live) do
if other == handle then
table.remove(live, index)
break
end
end
return nil, err
end
-- Deliberately not drained here: starting a child must not settle it, and
-- the first wake byte is already in the pipe by the time anyone waits.
return handle
end
-- True while a child with this id has a turn in flight, including one that
-- close_all cancelled but whose pump has not exited yet: it still owns that
-- child's session file, and a second writer over the same file loses data. The
-- refusal is transient — the pump's settle clears it on the next drain.
function M.active(id)
if id == nil then
return false
end
for _, handle in ipairs(live) do
if handle.id == id and handle.settled == nil then
return true
end
end
return false
end
-- ---------------------------------------------------------------------------
-- Awaiting
-- ---------------------------------------------------------------------------
-- "all" is satisfied only when every handle has settled; "first" as soon as one
-- has. Both answer in input order, and "first" hands the rest back by identity
-- so a caller can await them again.
local function collect(handles, mode)
if mode == "first" then
local results, remaining = {}, {}
for _, handle in ipairs(handles) do
if handle.settled ~= nil then
results[#results + 1] = handle.settled
else
remaining[#remaining + 1] = handle
end
end
if #results == 0 and #remaining > 0 then
return nil
end
return results, remaining
end
local results = {}
for index, handle in ipairs(handles) do
if handle.settled == nil then
return nil
end
results[index] = handle.settled
end
return results, {}
end
-- Resume every parked await whose condition now holds. Never resumes the
-- running coroutine: a coroutine that is executing cannot also be parked.
function wake()
local self_co = coroutine.running()
local index = 1
while index <= #waiters do
local waiter = waiters[index]
local results, remaining = collect(waiter.handles, waiter.mode)
if results ~= nil and waiter.co ~= self_co and coroutine.status(waiter.co) == "suspended" then
table.remove(waiters, index)
coroutine.resume(waiter.co, results, remaining)
else
index = index + 1
end
end
end
-- Parking is only safe when something will wake us: a started job with no wake
-- pipe is drained by asking it directly instead.
local function can_park(handles)
if not coroutine.isyieldable() then
return false
end
for _, handle in ipairs(handles) do
if handle.job ~= nil and handle.settled == nil and handle.poll == nil then
return false
end
end
return true
end
-- await(handles, mode) -> results, remaining
function M.await(handles, mode)
mode = mode or "all"
if type(handles) ~= "table" then
error("jobs.await expects an array of job handles", 2)
end
if mode ~= "all" and mode ~= "first" then
error("jobs.await mode must be \"all\" or \"first\"", 2)
end
while true do
local moved = drain_all()
local results, remaining = collect(handles, mode)
if results ~= nil then
return results, remaining
end
wake()
if can_park(handles) then
waiters[#waiters + 1] = { co = coroutine.running(), handles = handles, mode = mode }
local woken, rest = coroutine.yield()
if woken ~= nil then
return woken, rest
end
elseif not moved and ok_uv then
-- Nothing to drain and nothing to park on: yield the core rather
-- than burn it while the pump threads work.
uv.sleep(1)
end
end
end
-- ---------------------------------------------------------------------------
-- Turn lifecycle
-- ---------------------------------------------------------------------------
-- The turn was interrupted: ask every child to stop. Cancellation is a request,
-- not a settle — each job still reports its own cancelled result.
function M.cancel_all()
for index = #live, 1, -1 do
live[index]:cancel()
end
end
-- The turn is over: cancel every child and drop the state. Never blocks — a
-- child whose pump is still up is left open and closed by its own settle, so
-- the owner thread stays free for the tool batches those pumps are waiting on.
function M.close_all()
for _, handle in ipairs(live) do
if handle.job and handle.settled == nil then
pcall(handle.job.request_cancel, handle.job)
end
end
-- Whatever is still unsettled stays live with its wake poll still armed —
-- start() refuses a job that has none — so the byte its pump writes on the
-- way out still drives the settle that closes it and frees its pipe. The
-- next close_all sweeps up whatever settled since.
local closing = {}
for _, handle in ipairs(live) do
if handle.job and handle.settled == nil then
handle.close_on_settle = true
handle.state = "closing"
closing[#closing + 1] = handle
else
close_pipe(handle)
close_job(handle)
end
end
live, queued, waiters = closing, {}, {}
-- Those children are still running against the same bound; each releases
-- its slot in settle() when its pump finally exits.
running = #closing
end
return M
|