From 372ef8ff40991644ec2654c61328f31779f4ad21 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 17 Aug 2026 23:35:36 -0600 Subject: Validation-pass fixes; DESIGN.md describes the shipped seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jobs: a child whose wake pipe cannot be armed is refused up front instead of started into a state nothing can wake (the luv-less drain loop survives only for hosts without a loop); a closing child keeps its concurrency slot and its id's exclusivity until the pump actually exits, so teardown can no longer over-admit new children or let two turns share one session file. Resume-metadata reads honor the plain-error contract on a malformed store. workflow: the built-in schema subset validator is the only validator — the jsonschema probe made behavior depend on an undeclared rock (see rockspec: that dependency is deliberately rejected); dead exports and the unreachable half of the structured-output guard are gone, keeping the empty-arguments provider case. DESIGN.md's seam sections now describe the shipped division: binding-level async jobs and tool control, host-level resolve_model/ExtHost/turn events/ component handles, rock-level policy; protocol bodies run to completion on the loop thread and cannot yield, cancellation is scoped to the stream that opened it, and a child's compaction leaves protocol sessions alone. --- spec/test_jobs.lua | 64 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 5 deletions(-) (limited to 'spec/test_jobs.lua') diff --git a/spec/test_jobs.lua b/spec/test_jobs.lua index ce0525c..de36ab5 100644 --- a/spec/test_jobs.lua +++ b/spec/test_jobs.lua @@ -3,9 +3,10 @@ -- -- These cases drive the job machinery directly with hand-made fake jobs rather -- than through a child, so a failure here points at the gate and not at spawn --- policy. The fake jobs have no wake pipe (the harness has none), so awaiting --- drains them in place; `settle = N` means "settles on the Nth poll", which is --- how the ordering cases stay deterministic without an event loop. +-- policy. Real wake pipes are armed (a job that cannot get one is refused), but +-- nothing writes to them and no loop runs: a plain script cannot park, so +-- awaiting drains its jobs in place and `settle = N` means "settles on the Nth +-- poll", which is how the ordering cases stay deterministic without a loop. local fake = require("spec.fake_ext") local jobs = require("subagents.jobs") @@ -42,9 +43,7 @@ local function starter() local function start(name, spec) spec = spec or {} return jobs.start({ - label = name, id = spec.id, - one_shot = spec.one_shot, on_event = spec.on_event, build = function() built[#built + 1] = name @@ -261,4 +260,59 @@ return { assert(tostring(err):find("the host refused", 1, true), tostring(err)) end) end }, + + -- Under luv the wake pipe is not optional: without it nothing would ever + -- return the owner thread to the loop the child's own tool batches need. + { "a child whose wake pipe cannot be armed is refused instead of started", function() + local ok, uv = pcall(require, "luv") + if not ok or type(uv) ~= "table" then + return "skip", "luv is not installed" + end + with_jobs(function() + local start, built = starter() + uv.pipe = function() + return nil, "EMFILE" + end + local handle, err = start("alpha") + uv.pipe = nil -- back to the real luv through the harness metatable + assert(handle == nil, "a child with no wake pipe must not start") + assert(tostring(err):find("the subagent wake pipe could not be armed", 1, true), tostring(err)) + assert(#built == 0, "and its turn is never built") + end) + end }, + + -- close_all cannot join a pump that is still up, so that child is still + -- running against the same bound and still owns its session file. + { "a closing child holds its slot until it settles", function() + with_jobs(function() + local start, built = starter() + local closing = assert(start("alpha", { settle = 99 })) + jobs.close_all() + + local queued = assert(start("beta")) + assert(#built == 1, "the closing child still holds the only slot, saw " .. #built) + + jobs.await({ closing }, "all") + assert(#built == 2, "the slot comes back when the closing child settles") + + jobs.await({ queued }, "all") + local last = assert(start("gamma")) + assert(#built == 3, "and the bound is not leaked once everything has settled") + jobs.await({ last }, "all") + end, 1) + end }, + + { "a closing child still reports its id as active", function() + with_jobs(function() + local start = starter() + local handle = assert(start("alpha", { id = "child-1", settle = 99 })) + assert(jobs.active("child-1"), "a running child is active") + + jobs.close_all() + assert(jobs.active("child-1"), "a cancelled child still owns its session file") + + jobs.await({ handle }, "all") + assert(not jobs.active("child-1"), "the settle releases the id") + end) + end }, } -- cgit v1.3