AochenShen99 05db1d66a0
fix(workflows): stop idle agent waits from consuming worker turns (#7024)
* fix(workflows): stop idle agent waits from consuming worker turns

While a script step waited on a child Agent run, the supervisor sent an
empty response frame to the QuickJS worker every 20 ms and each round
trip counted toward the 10,000-turn cap, so any workflow whose Agent
calls kept it waiting for about 209 s failed with "Worker turn limit"
regardless of `timeout_seconds`.

The worker now reports when its job queue has drained, and the
supervisor waits on the pending calls locally while the worker is idle,
still re-checking cancellation and faults every 20 ms, without
exchanging frames. `timeout_seconds` is the only wall-clock bound again;
scripts that spin the job queue still hit the turn cap. The cap is a
module constant so tests can lower it.

Fixes bytedance/deer-flow#7022

* fix(workflows): default a frame without idle to the polling path

Review follow-up: read `idle` with `frame.get("idle", False)` so an
outcome-less frame that lacks the key degrades to the previous poll
behaviour instead of failing the run with a KeyError, and give the
cancel-latency regression test a 3 s budget so store writes and process
reaping on a loaded CI runner cannot flake it.

* fix(workflows): detect worker exit during idle Agent waits

Check worker exit in the existing local poll and clean up pending child runs. Adapt the work-frame budget regression from PR #7025 while retaining precise idle detection.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-18 20:26:17 +08:00

123 lines
4.8 KiB
Python

"""Isolated, capability-free JavaScript worker. Only bounded JSON crosses stdio."""
import json
import math
import sys
import quickjs
MAX_FRAME = 48 * 1024
BOOT = r"""
(() => {
const encode=JSON.stringify.bind(JSON), decode=JSON.parse.bind(JSON);
const requests=[], pending=new Map(); let serial=0, final=null;
const clone=value=>decode(encode(value));
const freeze=value=>{if(value&&typeof value==='object'){Object.values(value).forEach(freeze);Object.freeze(value);}return value;};
Object.defineProperty(globalThis,'agent',{value:Object.freeze(payload=>new Promise((resolve,reject)=>{
if(pending.size>=64){reject(new Error('Pending call limit'));return;}
const id=++serial; requests.push({type:'agent',id,payload:clone(payload)});pending.set(id,{resolve,reject});
})),writable:false,configurable:false});
for(const name of ['pause','humanCheckpoint','captureArtifact','readArtifact']) {
Object.defineProperty(globalThis,name,{value:Object.freeze(payload=>new Promise((resolve,reject)=>{
if(pending.size>=64){reject(new Error('Pending call limit'));return;}
const id=++serial; requests.push({type:name,id,payload:clone(payload)});pending.set(id,{resolve,reject});
})),writable:false,configurable:false});
}
Object.defineProperty(globalThis,'parallel',{value:Object.freeze(tasks=>{
if(!Array.isArray(tasks)||tasks.length>64) throw new Error('Parallel item limit');
return Promise.all(tasks.map(task=>task()));
}),writable:false,configurable:false});
Object.defineProperty(globalThis,'log',{value:Object.freeze(text=>{
if(requests.length>=64)throw new Error('Message limit');requests.push({type:'log',text:String(text)});
}),writable:false,configurable:false});
return {
launch:(source,args)=>{
Object.defineProperty(globalThis,'args',{value:freeze(decode(args)),writable:false,configurable:false});
const run=(0,eval)('(async()=>{"use strict";\n'+source+'\n})()');
run.then(value=>{final=encode({type:'result',value:value===undefined?null:value});},
error=>{final=encode({type:'error',message:String(error)});});
},
take:()=>encode(requests.splice(0)), result:()=>final,
deliver:raw=>{const m=decode(raw),p=pending.get(m.id);if(!p)throw new Error('Unknown response');
pending.delete(m.id);m.error?p.reject(new Error(m.error)):p.resolve(m.value);}
};
})()
"""
def read():
line = sys.stdin.buffer.readline(MAX_FRAME + 1)
if not line or len(line) > MAX_FRAME or not line.endswith(b"\n"):
raise ValueError("Invalid IPC frame")
return json.loads(line)
def write(value):
raw = json.dumps(value, ensure_ascii=True, allow_nan=False).encode() + b"\n"
if len(raw) > MAX_FRAME:
raise ValueError("IPC output limit")
sys.stdout.buffer.write(raw)
sys.stdout.buffer.flush()
def main():
request = read()
# No generated Python, callbacks, imports, environment or OS bindings are exposed.
# OS limits complement the JS engine; parent wall deadlines work on all hosts.
try:
import resource
resource.setrlimit(
resource.RLIMIT_CPU,
(math.ceil(request["cpu_seconds"]), math.ceil(request["cpu_seconds"]) + 1),
)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0))
except ImportError:
pass
ctx = quickjs.Context()
ctx.set_memory_limit(request["memory_bytes"])
ctx.set_max_stack_size(256 * 1024)
ctx.set_time_limit(0.05)
ctx.eval("globalThis.__bridge = " + BOOT)
launch = ctx.eval("__bridge.launch")
take = ctx.eval("__bridge.take")
result = ctx.eval("__bridge.result")
deliver = ctx.eval("__bridge.deliver")
ctx.eval("delete globalThis.__bridge")
launch(request["script"], json.dumps(request["args"]))
while True:
# Bound each JS turn and require a parent round trip between batches.
idle = False
for _ in range(64):
if not ctx.execute_pending_job():
idle = True
break
messages = json.loads(take())
outcome = result()
# idle: the job queue drained, so only a response can make further progress.
write({"messages": messages, "outcome": json.loads(outcome) if outcome else None, "idle": idle})
if outcome:
return
response = read()
for message in response["responses"]:
deliver(json.dumps(message))
if __name__ == "__main__":
try:
main()
except BaseException:
# Never return a Python traceback or worker environment to generated code.
write(
{
"messages": [],
"outcome": {
"type": "error",
"message": "Script failed or exceeded worker limits",
},
}
)
sys.exit(1)