结束一个进程,再来继续下一项
把“检查程序并报告”拆成 check → run → report,先保存依赖,再分别认领与完成。三个 work 命令是三个独立进程:后一个进程依靠任务文件接着做,而不是靠前一个 Python 进程还活着。思想篇解释了为什么完成只意味着下游可以开始。
源码在独立公开仓库 learn-agent-harness。Python3.10+,默认标准库,无需 API Key:
git clone https://github.com/Chengyunlai/learn-agent-harness.git
cd learn-agent-harness
python3 examples/s12-task-system/user_code/practice.py init
python3 examples/s12-task-system/user_code/practice.py show
python3 examples/s12-task-system/user_code/practice.py work --task check
python3 examples/s12-task-system/user_code/practice.py work --task run --decision allow
python3 examples/s12-task-system/user_code/practice.py work --task report可开始项依次为 check、run、report,最后为空。run 重新读取当前预期,获准执行后取得 42 并核对;report 引用已存的 run 结果,不再启动程序。初始化后的修订号为 3,三次认领和三次完成后为 9。
本例由 init 建好已知任务图,模型响应为固定脚本;真实读写任务文件、真实读取工作区、获准后真实运行程序。它验证任务机制,不证明真实模型已经学会拆解复杂项目。init 只对新任务目录运行;需要重做实验时,用 --store 指定另一空目录,已有 ID 不会被重置。
create_task:新记录先进入候选,不直接改原文件
每项记录保存 id、subject、description、status、owner、blocked_by 和 result。ID 用宿主给出的有界小写标识,在同一图里唯一;本课没有删除或重命名操作。创建时 status 固定为 pending,owner 为空,结果说明也为空。
create_task 重新读盘、拒绝重复 ID,在深复制的 candidate 中加入新项,再校验整份图。依赖必须已经存在且不能重复或指向自身。创建只引用旧项,后来又不能改边,这让新增边保持先后关系;加载文件仍额外检查环,避免手动改坏后继续运行。
真实执行轨迹回放;直接读写Worker临时虚拟任务文件,不访问宿主目录或调用模型。
def create_task(directory, task_id, subject, description, blocked_by):
"""依赖只能引用已存在任务;新项从pending开始,不能覆盖旧ID。"""
task_id = identifier(task_id)
board = load_board(directory)
if task_id in board['tasks']:
raise ValueError('任务ID已经存在,不覆盖。')
item = {'id': task_id, 'subject': subject, 'description': description, 'status': 'pending',
'owner': None, 'blocked_by': deepcopy(blocked_by), 'result': ''}
candidate = deepcopy(board)
candidate['tasks'][task_id] = item
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
return deepcopy(item)点击行号设置断点,再点“调试”;也可以直接运行。
教学准备与调用入口
上方源码保持原样。以下准备输入和调用入口,在单独文件名中执行;断点只回放上方源码。
from copy import deepcopy
import json
import os
from pathlib import Path
import re
import tempfile
scenario='new'
_temp=tempfile.TemporaryDirectory(prefix='lesson12-')
directory=Path(_temp.name)
def identifier(value):
if type(value) is not str or not re.fullmatch(r'[a-z][a-z0-9-]{0,31}',value):
raise ValueError('标识必须是1到32位小写字母、数字或连字符。')
return value
def checked_board(board):
if type(board) is not dict or set(board)!={'version','revision','tasks'} or type(board['version']) is not int or board['version']!=1 or type(board['revision']) is not int or not 0<=board['revision']<=10000:
raise ValueError('任务文件版本或修订号无效。')
tasks=board['tasks']
if type(tasks) is not dict or len(tasks)>20:
raise ValueError('最多保存20项任务。')
for key,item in tasks.items():
identifier(key)
if type(item) is not dict or set(item)!={'id','subject','description','status','owner','blocked_by','result'} or item['id']!=key:
raise ValueError('任务字段或ID不一致。')
for field,limit in [('subject',120),('description',600),('result',1200)]:
if type(item[field]) is not str or len(item[field])>limit or (field=='subject' and not item[field].strip()):
raise ValueError('任务文字为空或超过上限。')
deps=item['blocked_by']
if type(deps) is not list or len(deps)>20 or any(type(d) is not str or d not in tasks or d==key for d in deps) or len(set(deps))!=len(deps):
raise ValueError('依赖必须存在、唯一且不能指向自身。')
if item['status'] not in ('pending','in_progress','completed'):
raise ValueError('未知任务状态。')
if item['status']=='pending':
if item['owner'] is not None or item['result']:
raise ValueError('待认领任务不能已有负责人或结果。')
else:
identifier(item['owner'])
if bool(item['result'].strip())!=(item['status']=='completed'):
raise ValueError('只有完成任务需要非空结果说明。')
for item in tasks.values():
if item['status']!='pending' and any(tasks[d]['status']!='completed' for d in item['blocked_by']):
raise ValueError('进行中或完成任务仍有未完成依赖。')
def visit(key,visiting,done):
if key in visiting:raise ValueError('依赖存在环。')
if key in done:return
visiting.add(key)
for dep in tasks[key]['blocked_by']:visit(dep,visiting,done)
visiting.remove(key);done.add(key)
done=set()
for key in tasks:visit(key,set(),done)
return deepcopy(board)
def load_board(directory):
target=Path(directory)/'tasks.json'
try:
with target.open('rb') as stream:raw=stream.read(64001)
except FileNotFoundError:return {'version':1,'revision':0,'tasks':{}}
if len(raw)>64000:raise ValueError('任务文件超过64000字节。')
return checked_board(json.loads(raw.decode('utf-8')))
def save_board(directory,board):
board=checked_board(board)
payload=json.dumps(board,ensure_ascii=False,indent=2)
if len(payload.encode('utf-8'))>64000:raise ValueError('新任务文件超过64000字节。')
directory=Path(directory);directory.mkdir(parents=True,exist_ok=True);temporary=None
try:
with tempfile.NamedTemporaryFile(mode='w',encoding='utf-8',dir=directory,prefix='.tasks-',delete=False) as stream:
temporary=Path(stream.name);stream.write(payload);stream.flush();os.fsync(stream.fileno())
os.replace(temporary,directory/'tasks.json')
finally:
if temporary is not None:temporary.unlink(missing_ok=True)
def ready_ids(board):
tasks=checked_board(board)['tasks']
return sorted(key for key,item in tasks.items() if item['status']=='pending' and all(tasks[d]['status']=='completed' for d in item['blocked_by']))
def create_task(directory, task_id, subject, description, blocked_by):
"""依赖只能引用已存在任务;新项从pending开始,不能覆盖旧ID。"""
task_id = identifier(task_id)
board = load_board(directory)
if task_id in board['tasks']:
raise ValueError('任务ID已经存在,不覆盖。')
item = {'id': task_id, 'subject': subject, 'description': description, 'status': 'pending',
'owner': None, 'blocked_by': deepcopy(blocked_by), 'result': ''}
candidate = deepcopy(board)
candidate['tasks'][task_id] = item
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
return deepcopy(item)
def claim_task(directory, task_id, owner):
"""重新读盘,检查状态与全部依赖,再写入负责人;单写者约定。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'pending':
raise ValueError('任务已被认领或完成,不能再次认领。')
blocked = [dep for dep in item['blocked_by'] if board['tasks'][dep]['status'] != 'completed']
if blocked:
raise ValueError('依赖尚未完成:' + ','.join(blocked))
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='in_progress', owner=owner)
candidate['revision'] += 1
save_board(directory, candidate)
return deepcopy(candidate['tasks'][task_id])
def complete_task(directory, task_id, owner, result):
"""同一负责人提交结果说明;完成后重新求可开始项,不自动执行下游。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'in_progress' or item['owner'] != owner:
raise ValueError('只有进行中任务的负责人可以提交完成。')
before = ready_ids(board)
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='completed', result=result)
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
unlocked = [key for key in ready_ids(candidate) if key not in before]
return {'task': deepcopy(candidate['tasks'][task_id]), 'unlocked': unlocked}
create_task(directory,'check','检查文件','读取当前文件,不等于已经运行。',[])
create_task(directory,'run','运行并核对','取得本次许可和执行证据。',['check'])
create_task(directory,'report','报告已存结论','引用run记录,不自动重新运行。',['run'])
task_id='review' if 'create_task'=='create_task' else 'run'
owner='worker-a'
result='本次获准运行,exit_code=0,实际输出42。'
deps=['check']
if scenario not in ('blocked','pending') and 'create_task'!='create_task':
claim_task(directory,'check',owner)
complete_task(directory,'check',owner,'已读文件,尚未运行。')
if 'create_task'=='claim_task' and scenario=='taken':claim_task(directory,'run',owner)
if 'create_task'=='complete_task' and scenario!='pending':claim_task(directory,'run',owner)
if scenario=='duplicate':task_id='check'
if scenario=='missing':deps=['missing']
if scenario=='self':deps=['review']
if scenario=='bad_id':task_id='../other'
if scenario=='unknown':task_id='missing'
if scenario=='wrong_owner':owner='worker-b'
if scenario=='empty':result=''
before=(directory/'tasks.json').read_bytes()
_replace=os.replace
def broken_replace(*_):raise OSError('教学替换失败')
if scenario=='replace_error':os.replace=broken_replace
# 调用入口
try:
observed=create_task(directory,task_id,'复查记录','只更新任务图,不执行程序。',deps)
print(observed)
except (ValueError,OSError) as error:
observed={"error":str(error)}
print(str(error))
assert (directory/"tasks.json").read_bytes()==before
finally:
os.replace=_replace
- 读取原任务图并拒绝重复ID。
- 新项加入候选,校验已有依赖、状态与边。
- 完整校验后持久保存,创建不执行。
在加入候选的赋值行停住,可以看到原 board 与 candidate 尚未包含新项;单步后只在 candidate 中出现。再到 save_board 调用行,图会明确“下一步提交”,不会因为内存已经变化就把磁盘画成成功。
save_board 是三个写操作共用的存储边界:再次校验候选,序列化后检查 64000 字节上限,写同目录临时文件,flush/fsync 后 os.replace。最多保存 20 项任务;标题 120、描述 600、结果 1200 字符。替换失败时旧文件保持,临时文件清理;损坏原文件则拒绝操作,不把错误解释成空任务图。
claim_task:先确认能做,再把负责人写进去
函数输入任务 ID 和宿主负责人,先确认记录仍为 pending,然后逐个检查 blocked_by。只要有一项前置尚未完成,整个认领失败,原文件保持。空依赖会得到空的 blocked 列表,不需要额外放行标记。
通过后复制整份图,把目标候选改成 in_progress 并绑定 owner,增加修订号,再提交。修订号只表示本例发生过一次成功变更,不承担并发比较或锁的功能。返回记录是副本,调用者改返回值不会改磁盘。
真实执行轨迹回放;直接读写Worker临时虚拟任务文件,不访问宿主目录或调用模型。
def claim_task(directory, task_id, owner):
"""重新读盘,检查状态与全部依赖,再写入负责人;单写者约定。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'pending':
raise ValueError('任务已被认领或完成,不能再次认领。')
blocked = [dep for dep in item['blocked_by'] if board['tasks'][dep]['status'] != 'completed']
if blocked:
raise ValueError('依赖尚未完成:' + ','.join(blocked))
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='in_progress', owner=owner)
candidate['revision'] += 1
save_board(directory, candidate)
return deepcopy(candidate['tasks'][task_id])点击行号设置断点,再点“调试”;也可以直接运行。
教学准备与调用入口
上方源码保持原样。以下准备输入和调用入口,在单独文件名中执行;断点只回放上方源码。
from copy import deepcopy
import json
import os
from pathlib import Path
import re
import tempfile
scenario='ready'
_temp=tempfile.TemporaryDirectory(prefix='lesson12-')
directory=Path(_temp.name)
def identifier(value):
if type(value) is not str or not re.fullmatch(r'[a-z][a-z0-9-]{0,31}',value):
raise ValueError('标识必须是1到32位小写字母、数字或连字符。')
return value
def checked_board(board):
if type(board) is not dict or set(board)!={'version','revision','tasks'} or type(board['version']) is not int or board['version']!=1 or type(board['revision']) is not int or not 0<=board['revision']<=10000:
raise ValueError('任务文件版本或修订号无效。')
tasks=board['tasks']
if type(tasks) is not dict or len(tasks)>20:
raise ValueError('最多保存20项任务。')
for key,item in tasks.items():
identifier(key)
if type(item) is not dict or set(item)!={'id','subject','description','status','owner','blocked_by','result'} or item['id']!=key:
raise ValueError('任务字段或ID不一致。')
for field,limit in [('subject',120),('description',600),('result',1200)]:
if type(item[field]) is not str or len(item[field])>limit or (field=='subject' and not item[field].strip()):
raise ValueError('任务文字为空或超过上限。')
deps=item['blocked_by']
if type(deps) is not list or len(deps)>20 or any(type(d) is not str or d not in tasks or d==key for d in deps) or len(set(deps))!=len(deps):
raise ValueError('依赖必须存在、唯一且不能指向自身。')
if item['status'] not in ('pending','in_progress','completed'):
raise ValueError('未知任务状态。')
if item['status']=='pending':
if item['owner'] is not None or item['result']:
raise ValueError('待认领任务不能已有负责人或结果。')
else:
identifier(item['owner'])
if bool(item['result'].strip())!=(item['status']=='completed'):
raise ValueError('只有完成任务需要非空结果说明。')
for item in tasks.values():
if item['status']!='pending' and any(tasks[d]['status']!='completed' for d in item['blocked_by']):
raise ValueError('进行中或完成任务仍有未完成依赖。')
def visit(key,visiting,done):
if key in visiting:raise ValueError('依赖存在环。')
if key in done:return
visiting.add(key)
for dep in tasks[key]['blocked_by']:visit(dep,visiting,done)
visiting.remove(key);done.add(key)
done=set()
for key in tasks:visit(key,set(),done)
return deepcopy(board)
def load_board(directory):
target=Path(directory)/'tasks.json'
try:
with target.open('rb') as stream:raw=stream.read(64001)
except FileNotFoundError:return {'version':1,'revision':0,'tasks':{}}
if len(raw)>64000:raise ValueError('任务文件超过64000字节。')
return checked_board(json.loads(raw.decode('utf-8')))
def save_board(directory,board):
board=checked_board(board)
payload=json.dumps(board,ensure_ascii=False,indent=2)
if len(payload.encode('utf-8'))>64000:raise ValueError('新任务文件超过64000字节。')
directory=Path(directory);directory.mkdir(parents=True,exist_ok=True);temporary=None
try:
with tempfile.NamedTemporaryFile(mode='w',encoding='utf-8',dir=directory,prefix='.tasks-',delete=False) as stream:
temporary=Path(stream.name);stream.write(payload);stream.flush();os.fsync(stream.fileno())
os.replace(temporary,directory/'tasks.json')
finally:
if temporary is not None:temporary.unlink(missing_ok=True)
def ready_ids(board):
tasks=checked_board(board)['tasks']
return sorted(key for key,item in tasks.items() if item['status']=='pending' and all(tasks[d]['status']=='completed' for d in item['blocked_by']))
def create_task(directory, task_id, subject, description, blocked_by):
"""依赖只能引用已存在任务;新项从pending开始,不能覆盖旧ID。"""
task_id = identifier(task_id)
board = load_board(directory)
if task_id in board['tasks']:
raise ValueError('任务ID已经存在,不覆盖。')
item = {'id': task_id, 'subject': subject, 'description': description, 'status': 'pending',
'owner': None, 'blocked_by': deepcopy(blocked_by), 'result': ''}
candidate = deepcopy(board)
candidate['tasks'][task_id] = item
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
return deepcopy(item)
def claim_task(directory, task_id, owner):
"""重新读盘,检查状态与全部依赖,再写入负责人;单写者约定。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'pending':
raise ValueError('任务已被认领或完成,不能再次认领。')
blocked = [dep for dep in item['blocked_by'] if board['tasks'][dep]['status'] != 'completed']
if blocked:
raise ValueError('依赖尚未完成:' + ','.join(blocked))
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='in_progress', owner=owner)
candidate['revision'] += 1
save_board(directory, candidate)
return deepcopy(candidate['tasks'][task_id])
def complete_task(directory, task_id, owner, result):
"""同一负责人提交结果说明;完成后重新求可开始项,不自动执行下游。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'in_progress' or item['owner'] != owner:
raise ValueError('只有进行中任务的负责人可以提交完成。')
before = ready_ids(board)
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='completed', result=result)
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
unlocked = [key for key in ready_ids(candidate) if key not in before]
return {'task': deepcopy(candidate['tasks'][task_id]), 'unlocked': unlocked}
create_task(directory,'check','检查文件','读取当前文件,不等于已经运行。',[])
create_task(directory,'run','运行并核对','取得本次许可和执行证据。',['check'])
create_task(directory,'report','报告已存结论','引用run记录,不自动重新运行。',['run'])
task_id='review' if 'claim_task'=='create_task' else 'run'
owner='worker-a'
result='本次获准运行,exit_code=0,实际输出42。'
deps=['check']
if scenario not in ('blocked','pending') and 'claim_task'!='create_task':
claim_task(directory,'check',owner)
complete_task(directory,'check',owner,'已读文件,尚未运行。')
if 'claim_task'=='claim_task' and scenario=='taken':claim_task(directory,'run',owner)
if 'claim_task'=='complete_task' and scenario!='pending':claim_task(directory,'run',owner)
if scenario=='duplicate':task_id='check'
if scenario=='missing':deps=['missing']
if scenario=='self':deps=['review']
if scenario=='bad_id':task_id='../other'
if scenario=='unknown':task_id='missing'
if scenario=='wrong_owner':owner='worker-b'
if scenario=='empty':result=''
before=(directory/'tasks.json').read_bytes()
_replace=os.replace
def broken_replace(*_):raise OSError('教学替换失败')
if scenario=='replace_error':os.replace=broken_replace
# 调用入口
try:
observed=claim_task(directory,task_id,owner)
print(observed)
except (ValueError,OSError) as error:
observed={"error":str(error)}
print(str(error))
assert (directory/"tasks.json").read_bytes()==before
finally:
os.replace=_replace
- 重新读盘,确认pending及全部依赖完成。
- 将负责人和in_progress写入候选。
- 提交后才认领成功,不承担并发锁。
“依赖未完成”会显示真实 blocked 列表,且没有持久提交;“已经被认领”在状态检查处就失败。进入候选更新以后,左侧当前记录仍为 pending,右侧候选才变为 in_progress。两个位置表达的是原记录与待提交记录,不能合并成一次提前发生的状态变化。
模型工具的 task_claim 参数只有 task_id;owner 与目录在注册时由宿主绑定。它不能通过在参数里加 owner=worker-b 来替换负责人,也不能指定另一个文件路径。这个约定仍需要单写者环境,不能据此宣称并发认领已受锁保护。
complete_task:完成先保存,解锁项随后重新计算
完成要求当前状态为 in_progress,并且提交者与原 owner 相同。结果说明不能为空,但字段校验只能保证格式,不能证明文字一定属实。固定流程在实际核对成功以后才调用完成;直接调用函数的人仍要为结果说明提供证据。
函数先求提交前的 ready_ids,再准备 completed 候选并保存。保存返回以后,重新求可开始项,与原集合比较,只返回本次新增的 unlocked。原本就能开始的独立任务,不会被重复算成本次解锁成果。
真实执行轨迹回放;直接读写Worker临时虚拟任务文件,不访问宿主目录或调用模型。
def complete_task(directory, task_id, owner, result):
"""同一负责人提交结果说明;完成后重新求可开始项,不自动执行下游。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'in_progress' or item['owner'] != owner:
raise ValueError('只有进行中任务的负责人可以提交完成。')
before = ready_ids(board)
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='completed', result=result)
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
unlocked = [key for key in ready_ids(candidate) if key not in before]
return {'task': deepcopy(candidate['tasks'][task_id]), 'unlocked': unlocked}点击行号设置断点,再点“调试”;也可以直接运行。
教学准备与调用入口
上方源码保持原样。以下准备输入和调用入口,在单独文件名中执行;断点只回放上方源码。
from copy import deepcopy
import json
import os
from pathlib import Path
import re
import tempfile
scenario='done'
_temp=tempfile.TemporaryDirectory(prefix='lesson12-')
directory=Path(_temp.name)
def identifier(value):
if type(value) is not str or not re.fullmatch(r'[a-z][a-z0-9-]{0,31}',value):
raise ValueError('标识必须是1到32位小写字母、数字或连字符。')
return value
def checked_board(board):
if type(board) is not dict or set(board)!={'version','revision','tasks'} or type(board['version']) is not int or board['version']!=1 or type(board['revision']) is not int or not 0<=board['revision']<=10000:
raise ValueError('任务文件版本或修订号无效。')
tasks=board['tasks']
if type(tasks) is not dict or len(tasks)>20:
raise ValueError('最多保存20项任务。')
for key,item in tasks.items():
identifier(key)
if type(item) is not dict or set(item)!={'id','subject','description','status','owner','blocked_by','result'} or item['id']!=key:
raise ValueError('任务字段或ID不一致。')
for field,limit in [('subject',120),('description',600),('result',1200)]:
if type(item[field]) is not str or len(item[field])>limit or (field=='subject' and not item[field].strip()):
raise ValueError('任务文字为空或超过上限。')
deps=item['blocked_by']
if type(deps) is not list or len(deps)>20 or any(type(d) is not str or d not in tasks or d==key for d in deps) or len(set(deps))!=len(deps):
raise ValueError('依赖必须存在、唯一且不能指向自身。')
if item['status'] not in ('pending','in_progress','completed'):
raise ValueError('未知任务状态。')
if item['status']=='pending':
if item['owner'] is not None or item['result']:
raise ValueError('待认领任务不能已有负责人或结果。')
else:
identifier(item['owner'])
if bool(item['result'].strip())!=(item['status']=='completed'):
raise ValueError('只有完成任务需要非空结果说明。')
for item in tasks.values():
if item['status']!='pending' and any(tasks[d]['status']!='completed' for d in item['blocked_by']):
raise ValueError('进行中或完成任务仍有未完成依赖。')
def visit(key,visiting,done):
if key in visiting:raise ValueError('依赖存在环。')
if key in done:return
visiting.add(key)
for dep in tasks[key]['blocked_by']:visit(dep,visiting,done)
visiting.remove(key);done.add(key)
done=set()
for key in tasks:visit(key,set(),done)
return deepcopy(board)
def load_board(directory):
target=Path(directory)/'tasks.json'
try:
with target.open('rb') as stream:raw=stream.read(64001)
except FileNotFoundError:return {'version':1,'revision':0,'tasks':{}}
if len(raw)>64000:raise ValueError('任务文件超过64000字节。')
return checked_board(json.loads(raw.decode('utf-8')))
def save_board(directory,board):
board=checked_board(board)
payload=json.dumps(board,ensure_ascii=False,indent=2)
if len(payload.encode('utf-8'))>64000:raise ValueError('新任务文件超过64000字节。')
directory=Path(directory);directory.mkdir(parents=True,exist_ok=True);temporary=None
try:
with tempfile.NamedTemporaryFile(mode='w',encoding='utf-8',dir=directory,prefix='.tasks-',delete=False) as stream:
temporary=Path(stream.name);stream.write(payload);stream.flush();os.fsync(stream.fileno())
os.replace(temporary,directory/'tasks.json')
finally:
if temporary is not None:temporary.unlink(missing_ok=True)
def ready_ids(board):
tasks=checked_board(board)['tasks']
return sorted(key for key,item in tasks.items() if item['status']=='pending' and all(tasks[d]['status']=='completed' for d in item['blocked_by']))
def create_task(directory, task_id, subject, description, blocked_by):
"""依赖只能引用已存在任务;新项从pending开始,不能覆盖旧ID。"""
task_id = identifier(task_id)
board = load_board(directory)
if task_id in board['tasks']:
raise ValueError('任务ID已经存在,不覆盖。')
item = {'id': task_id, 'subject': subject, 'description': description, 'status': 'pending',
'owner': None, 'blocked_by': deepcopy(blocked_by), 'result': ''}
candidate = deepcopy(board)
candidate['tasks'][task_id] = item
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
return deepcopy(item)
def claim_task(directory, task_id, owner):
"""重新读盘,检查状态与全部依赖,再写入负责人;单写者约定。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'pending':
raise ValueError('任务已被认领或完成,不能再次认领。')
blocked = [dep for dep in item['blocked_by'] if board['tasks'][dep]['status'] != 'completed']
if blocked:
raise ValueError('依赖尚未完成:' + ','.join(blocked))
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='in_progress', owner=owner)
candidate['revision'] += 1
save_board(directory, candidate)
return deepcopy(candidate['tasks'][task_id])
def complete_task(directory, task_id, owner, result):
"""同一负责人提交结果说明;完成后重新求可开始项,不自动执行下游。"""
task_id, owner = identifier(task_id), identifier(owner)
board = load_board(directory)
if task_id not in board['tasks']:
raise ValueError('未知任务。')
item = board['tasks'][task_id]
if item['status'] != 'in_progress' or item['owner'] != owner:
raise ValueError('只有进行中任务的负责人可以提交完成。')
before = ready_ids(board)
candidate = deepcopy(board)
candidate['tasks'][task_id].update(status='completed', result=result)
candidate['revision'] += 1
checked_board(candidate)
save_board(directory, candidate)
unlocked = [key for key in ready_ids(candidate) if key not in before]
return {'task': deepcopy(candidate['tasks'][task_id]), 'unlocked': unlocked}
create_task(directory,'check','检查文件','读取当前文件,不等于已经运行。',[])
create_task(directory,'run','运行并核对','取得本次许可和执行证据。',['check'])
create_task(directory,'report','报告已存结论','引用run记录,不自动重新运行。',['run'])
task_id='review' if 'complete_task'=='create_task' else 'run'
owner='worker-a'
result='本次获准运行,exit_code=0,实际输出42。'
deps=['check']
if scenario not in ('blocked','pending') and 'complete_task'!='create_task':
claim_task(directory,'check',owner)
complete_task(directory,'check',owner,'已读文件,尚未运行。')
if 'complete_task'=='claim_task' and scenario=='taken':claim_task(directory,'run',owner)
if 'complete_task'=='complete_task' and scenario!='pending':claim_task(directory,'run',owner)
if scenario=='duplicate':task_id='check'
if scenario=='missing':deps=['missing']
if scenario=='self':deps=['review']
if scenario=='bad_id':task_id='../other'
if scenario=='unknown':task_id='missing'
if scenario=='wrong_owner':owner='worker-b'
if scenario=='empty':result=''
before=(directory/'tasks.json').read_bytes()
_replace=os.replace
def broken_replace(*_):raise OSError('教学替换失败')
if scenario=='replace_error':os.replace=broken_replace
# 调用入口
try:
observed=complete_task(directory,task_id,owner,result)
print(observed)
except (ValueError,OSError) as error:
observed={"error":str(error)}
print(str(error))
assert (directory/"tasks.json").read_bytes()==before
finally:
os.replace=_replace
- 确认进行中状态及相同负责人。
- 保存完成与结果说明。
- 比较提交前后ready,只返回新解锁项,不自动执行。
在保存调用行停下,磁盘仍是进行中记录;下一步保存返回,才进入解锁计算。完成 run 的正常输入只解锁 report,report 的状态仍是 pending。函数没有去认领它,也没有启动它的任何动作。
负责人不符、尚未认领、缺少结果说明和替换文件失败,都不会写出完成记录。“替换文件失败”的教学输入专门注入了一个公开可查看的接口故障,不能把候选中的 completed 当作持久文件已经完成。
五个任务工具,怎样接进已有的循环?
三个写函数之外,还有 task_list 与 task_get,分别读取概要与完整描述/结果。工具名刻意区分第六课的 task:task 是一次子调查调用,task_claim 等操作的是持久工作记录。本例仍只有原来的 run_loop 负责保存模型请求、关联工具结果和决定继续或停止。
装配时把目录与负责人绑定到任务工具,再并入原注册表;第十课沿真实注册生成 system 与 tools,因此这次新增的能力也进入同一份请求。任务写操作走既有参数分发和 Hook,原 run_main 继续经过第三课许可。
# s12/core/runtime.py 的任务工具绑定形状(完整注册见公开源码)
"task_claim": {
"description": "检查pending及依赖后认领;负责人由宿主绑定。",
"parameters": {"task_id": str},
"run": lambda task_id: text(claim_task(directory, task_id, owner)),
}
# s12/user_code/practice.py 的装配
task_tools = build_task_tools(directory, owner)
tools.update(task_tools)
# ask仍复用s11请求恢复;完整响应返回给原run_loop,再分发工具。当前工具参数沿用既有的必填字符串契约,因此 task_create 的 dependencies 是 JSON 数组文本,例如 '["check"]',适配器解析以后交给 create_task 的 blocked_by 列表。函数正文和工具参数形状是两个边界,不能把字符串直接当作依赖列表使用。
第八课的历史整理、第十课的提示组装、第十一课的父请求恢复继续复用;原清单、技能和子任务注册也保留。当前固定脚本只调用这次需要的工具。SDK 验证在完成提交后的模型请求注入 529,重发只发生在请求层,任务不会重复完成,程序也不会重跑。
拒绝、重启和并发,分别验证到哪里?
从新的任务目录初始化,先完成 check,再把 run 的许可设为 deny。模型可以正常回答“未执行”,旧循环状态甚至是 answered,但 run 仍为 in_progress,report 仍被阻塞。CLI 会根据持久任务是否完成返回退出码 1,不能把“拿到了回答”当作“任务已经完成”。
python3 examples/s12-task-system/user_code/practice.py work --task run --decision deny
python3 examples/s12-task-system/user_code/practice.py show
python3 examples/s12-task-system/user_code/verify_practice.py
python3 examples/s12-task-system/user_code/verify_fixtures.py再次启动 work --task run 不会自动重跑,因为认领检查发现任务已经进行中。若上次已执行但未提交,应先查实际产物与记录,再通过 complete 命令明确提交说明;本课没有自动回退、抢占或恰好一次执行机制。完整输出与预期不一致时也保留进行中,等待检查。
标准库测试会将真实程序改成每次运行就在计数文件追加 x,三个独立 work 结束后必须恰好只有一个 x。14 组测试还覆盖损坏文件、依赖环、原子替换失败和拒绝不执行;15 组浏览器绑定直接在 Worker 临时虚拟文件中执行原样函数与同源 helper,替换故障才使用明确注入,不访问宿主目录。
SDK 使用固定 Anthropic1.13.0 与模拟 HTTP,允许加过载恢复路径为 15 次请求,拒绝与阻塞路径为 11 次;任务文件和进程真实执行,没有在线模型调用。本课不提供 --live,先把可验证的任务边界跑清楚。
同一任务目录必须串行使用。临时文件加原子替换让单次提交完整,却没有锁住“读取、检查、写入”这一整段;需要多个工作者同时认领时,应另加锁或数据库事务。任务状态告诉你下一件事是否可开始,证据与权限决定这件事究竟能否、是否已经完成。