summaryrefslogtreecommitdiff
path: root/subagents/jobs.lua
blob: a8cde6dff1237e9615ca98298a457bdf5a6df7bf (plain)
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
-- 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. Where there is no coroutine
-- to park (a plain script) or a job with no wake pipe, the identical drain
-- runs in a loop instead — same gate, same settle bookkeeping, only the wait
-- differs.
--
-- 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.

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

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
    if handle.state == "running" then
        running = running - 1
    end
    handle.state = "settled"
    close_pipe(handle)
    pump_queue()
end

local function launch(handle)
    handle.fds = open_pipe()
    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.fds and ok_uv then
        local armed, poll = pcall(uv.new_poll, handle.fds.read)
        if armed and poll then
            handle.poll = poll
            poll:start("r", function()
                drain(handle)
                wake()
            end)
        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
--   label     = string?,  id = string?,  one_shot = boolean?
--   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,
        label = spec.label,
        id = spec.id,
        one_shot = spec.one_shot == true,
        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.
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.state == "running" 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: join every pump and drop the state. Requests go out first
-- so the joins overlap instead of running one child's teardown at a time.
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
    for _, handle in ipairs(live) do
        if handle.job then
            pcall(handle.job.close, handle.job)
            handle.job = nil
        end
        close_pipe(handle)
        handle.state = "closed"
    end
    live, queued, waiters = {}, {}, {}
    running = 0
end

return M