mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 06:00:38 +00:00
* 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>
123 lines
4.8 KiB
Python
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)
|