编码代理中断后如何恢复任务

一个可运行的本地 harness 实验:代理调用假外部服务后超时或被 SIGKILL,重启查询结果;文件摘要变化时停止覆盖,并把恢复证据与制品版本绑定。

·28 minAI CodingAI开发工具
把应用部署到土耳其|BRNCHOST · 土耳其 VDS
云服务器,积分可续期|雨云 · 国内外节点 · 积分兑换权益
低价年付,大流量 VPS|RackNerd · SSD 存储 · 1Gbps 端口
香港轻量,搭个小站|晚安云 · 香港云服务器
香港 VPS,大带宽可选|野草云 · BGP 直连
大陆访问,精品线路|搬瓦工 · CN2 GIA / CTGNet 套餐
资料归档,交给 AI 整理|WorkBuddy · 本地文件处理
建站起步,先看应用镜像|腾讯云 · 轻量应用服务器
CN2 GIA,中国方向优化|DMIT · Premium 网络
双 ISP 原生住宅 IP|丽萨主机 · 美国 9929 精品线路
高频 CPU,多地部署|Evoxt · 云服务器 · 每周异地备份
京东云轻量云主机:129元/年,新人专享,限购1台

代理准备修改一份文件,并把生成的制品登记到外部服务。登记请求发出去后没有响应,执行进程又退出了。用户在这段时间修改了原文件。新进程接手时面对两个问题:登记到底完成没有,原先准备的补丁还能不能应用。模型可以继续分析任务,但这些事实要从外部回执和当前文件中取得,上一轮对话里的“正在保存”无法回答它们。

本文用一个本地运行器重现这个过程。Node v25.5.0 启动假 HTTP 服务和独立执行子进程,文件操作限制在新建的临时目录中。假服务记录副作用后故意不返回响应,一组请求等到客户端超时,另一组在服务提交后遭到 SIGKILL。接管进程查询同一个动作键,随后完成一个受控的文件摘要冲突实验。

实验实际运行于二〇二六年九月八日,没有调用真实发布接口,也没有生产凭据。下文内嵌完整 lab.mjs,运行它即可创建独立目录并保存结果,无需下载附件。本文讨论的是执行器如何保留事实和恢复任务,不把这个短程序当作可直接处理不可信代码的沙箱。

先列出任务、动作和制品

任务是用户要完成的工作,例如修改说明文件并登记制品。动作是执行器可以独立调度、记录和接管的一次操作,例如读取文件、提交登记、查询登记结果、交付候选文件。制品则是某个具体版本的内容。把三者放进同一条“任务正在运行”记录里,重启后就无法判断哪一步有副作用、哪份内容已经检查。

本例使用一个任务文件和几个追加式动作日志,字段保持足够小。任务记录路径范围、输入摘要和外部动作键;外部动作记录授权、派发与结果;文件动作记录输入及输出摘要。为了便于阅读,下面把磁盘文件对应关系列出来:

记录 实验中的内容 接管时用途
task.json 任务标识、写入目录、输入摘要、两个动作键 确认当前任务约定
timeout.jsonl 超时分支的状态序列 判断需要查询的动作
killed.jsonl 进程中断前后的状态序列 找到尚未确认的派发
service.jsonl 假服务提交记录和回执 对照外部副作用
files.jsonl 摘要冲突与后续交付摘要 解释文件保留和更新
report.json 进程退出原因及断言结果 汇总本次实际运行

这些是示例存储格式,没有冒充某个产品 SDK。正式实现可以用关系表保存任务和动作,用唯一键约束动作身份,并把制品写进对象存储。无论采用哪种存储,接管者都应该能从动作找到当时的参数、授权依据和制品版本,而非只找到一段自然语言摘要。

参数摘要也有明确用途。同一个动作键应代表同一意图;文件内容或请求参数发生变化后,不能继续把它当作先前那件事重试。本例用 SHA-256 计算固定登记内容 artifact-v1 的摘要,假服务遇到同键不同摘要会返回冲突。修订回归实际发送了同键异参请求,假服务返回四〇九,执行器写入 conflict;随后查询到原意图的回执,也没有把它算作新意图成功。

任务文件在演示代码中由父进程构造,动作工作进程只读取自己的日志进行恢复。它没有实现从任务表推导完整执行计划的调度器。拆开这个限制有助于接手者判断下一步:目前证明了动作恢复和文件冲突处理,任务级依赖排序、租约与多执行器争抢仍需实现。

派发之前落盘,派发之后保留不确定性

执行子进程先追加 authorized,记录动作键和参数摘要;再追加 dispatched,然后发送 HTTP 请求。每次追加之后调用 fsyncSync,随后关闭文件描述符。两条记录不能交换顺序:如果先发请求再记派发,进程恰好在两者之间崩溃,接管者可能把已经发送的动作当成尚未执行。

function durable(file, obj) {
  const fd = fs.openSync(file, 'a');
  fs.writeSync(fd, JSON.stringify(obj) + '\n');
  fs.fsyncSync(fd);
  fs.closeSync(fd);
}

这个函数让实验在结束进程之前完成小记录的写入和同步。它没有实现通用日志系统的校验、部分写入循环、日志轮换或损坏尾部恢复。Node 的 v25.5.0 文件系统文档给出了这些 API 的接口依据;证据目录保存了对应版本官方页面。fsync 的使用不能让整套程序自动获得断电一致性,目录项、设备和文件系统语义还需要另行设计。

即使 dispatched 已经落盘,也无法断言远端收到了请求。进程可能在记录后、发送前退出。相反,没有成功响应也无法断言远端没提交。接管者应把这段窗口内的动作视为结果待查,查询外部系统,或在对方给出可靠幂等约定时重放相同动作键。

“派发”记录描述执行器已经进入可能产生副作用的阶段,不承担远端提交证明。它的价值在于让恢复流程采取保守且明确的分支。若把该状态直接映射为失败并再次生成新动作键,就可能登记两次;若直接映射为成功,又可能遗漏实际没发出去的工作。

本例日志没有把 failed 当成所有异常的收容状态。客户端超时写入 outcome_unknown,进程被强制终止则来不及写入任何收尾记录,最后仍停在 dispatched。接管者对这两种情况都进入 reconciling,用外部回执决定后续状态。

第一条分支:服务提交,客户端超时

假服务收到 POST 后读取正文,计算摘要,按动作键查询已有记录。若没有记录,就生成回执、追加服务日志并更新进程内映射。随后它故意不调用响应结束方法。修订版客户端等待五百毫秒,触发 AbortSignal.timeout,捕获异常并写入结果未知。

日志中的超时动作键为 timeout。它的正常状态序列如下,运行器会在本地 timeout.jsonl 中保存具体参数摘要和异常名称:

authorized -> dispatched -> outcome_unknown
           -> reconciling -> succeeded

图中的折行只是排版,磁盘日志按发生顺序逐条追加。父进程随后启动一个新的工作进程,使用 recover 阶段参数,读取上一条状态。工作进程不发送第二个 POST,而是 GET 同一个动作键的查询地址。假服务返回已保存的回执,工作进程核对动作键、持久化参数摘要、完成状态与非空回执编号后,才追加 succeeded。发送响应与恢复查询共用这项核验,任一字段不匹配都保留结果未知。

原始服务记录显示这个键只有一次副作用,回执为 receipt-timeout。这证明本轮接管通过查询确认了第一次请求的结果,没有重做登记。它没有验证服务重启后查询仍然可用:假服务虽然把提交内容追加到文件,但没有实现启动时从文件重建映射,整个实验期间服务进程保持运行。

这个限制关系到真实系统的恢复承诺。如果服务只在内存里记幂等键,执行器重启能恢复、服务重启却忘记历史,双方组合起来仍可能产生重复操作。接入真实供应商时,要核对键的保留时间、查询的一致性、同键异参行为以及账户作用域。不能把 HTTP 接口里出现一个 idempotency-key 字段,等同于已经知道这些约定。

对查询返回不存在的情况也要谨慎。请求可能尚在服务队列,副作用可能已经发生但查询索引还未更新,或者查询窗口已经超过记录保留期。本例查询不到时保留 outcome_unknown,没有自动重试。实际系统可以在明确的强一致查询或幂等协议支持下加入重试,否则应交给操作人处理并保留已知证据。

第二条分支:在服务提交后杀死工作进程

第二个动作键为 killed。假服务记录提交后安排父进程向当前工作子进程发送 SIGKILL。工作进程没有机会执行异常处理或 finally,退出报告中 code 为 null,signal 为 SIGKILL。最后一条本地动作记录仍是派发。

父进程再启动恢复子进程,查询同一个键。状态从 dispatched 进入 reconciling,随后得到 receipt-killed 并记录成功。假服务的副作用次数也是一。与第一组的差别在于本地未知状态是否来得及落盘,接管决策没有因此改变。

这次注入实际终止了独立工作进程,没有用函数抛异常代替进程崩溃。父进程和假服务继续运行,因此可以观察并保存退出原因。它验证的是执行器工作进程的中断恢复,不包含整机断电、父进程消失、网络分区或外部服务宕机。Node 的 子进程官方文档解释了退出码和信号字段;运行器会在 report.json 中记录退出码和信号,本轮结果如上所述。

用强制终止测试有一个实际好处:它迫使恢复逻辑依赖崩溃前留下的事实。单纯在函数中捕获异常的测试可能总能执行清理,于是遗漏日志尚未补全时的接管分支。设计故障注入时,应把停止点放在意图落盘后、外部提交后和回执落盘前等具体位置,说明每个停止点预期保留什么。

本轮两个分支都选择了外部提交后的窗口,未运行“意图落盘后但尚未发送”的中断。后者恢复时也可能先查询,再按对方协议决定发送。不能因为前两个分支通过,就宣称覆盖了派发链路上全部崩溃窗口。技术记录明确保留这一缺口。

收到响应头以后还要确认什么

调用 fetch 得到 Response 时,服务器可能返回冲突、内部错误或仅接受任务的状态。执行器此时掌握的是一次通信结果。修订版只把二〇〇或二〇一当作可能完成的入口,接着解析回执并校验动作键、参数摘要、已提交状态和非空回执编号。二〇二表示服务尚未给出最终完成证据;五〇〇、正文损坏或读取中断也不够确认业务,统一保留结果未知。

同键异参的四〇九按本假服务协议记录冲突,不能发起新的登记来消除这个错误。调用者应先查清该键对应的原意图,再决定是否创建新的业务动作。执行器不会把四〇九解释成这次修改已经完成,也不会把它写成远端从未发生任何效果;先前那件事可能已经提交。

恢复进程从动作日志中读取最近一次授权所绑定的摘要,而不是相信新进程临时传来的参数。回归先用正常键登记原内容,再用同键发送不同内容。服务保留原登记并拒绝新意图;恢复查询虽返回二〇〇,回执摘要仍属于原内容,执行器因此记录结果未知。查询成功与查询结果属于当前动作,是两个需要分别检查的条件。

十一项内存响应检查覆盖四〇九、五〇〇、二〇二、损坏正文、空值、错误键、错误摘要、空回执、未完成状态、有效回执和正文读取中断。只有有效回执进入成功。另有真实回环请求验证异参四〇九和恢复摘要不匹配,避免只测试辅助函数却漏掉工作进程的调用路径。这些回执来自本地假服务,身份信任与传输认证仍由真实接入协议负责。

用户改过文件,旧补丁先停下来

外部结果查清以后,代理还要交付文件。实验先写入 original,计算输入摘要,再用 user edit 模拟用户在代理准备期间编辑了原文件。交付前重新读文件并计算摘要,发现与原摘要不同,于是在文件日志追加 conflict,记录预期值、当前值和保留下来的内容。

此时程序没有把代理旧内容覆盖回去。证据中的 preserved 为用户编辑后的文本,读者可以把输入摘要与当前摘要对照。这个检查针对字节内容:换行、编码或空白变化同样会改变摘要。它不会判断两个修改在语义上是否兼容,后续合并仍需读取新版本并分析。

实验接着以用户版本作为新的基线,生成 user edit 加 agent addition 的候选内容。确认当前摘要仍等于新基线后,用临时候选文件重命名替换目标,并记录输出摘要。这里的“重新基于用户版本生成”由脚本预设完成,没有调用模型判断冲突,也没有伪装成人工批准;它展示的是冲突后的受控继续路径。

文件阶段 观察 执行动作
原始读取 保存输入摘要 允许准备候选
用户修改后 当前摘要与输入不同 记录冲突并保留文件
采用新基线 候选包含用户内容 检查新基线并替换
替换完成 输出摘要已记录 把交付关联到该版本

摘要比较还有一个容易被忽略的窗口。读取并计算摘要之后、重命名之前,另一个写入者仍可能再次修改目标。hash 检查加 rename 并不构成原子的比较交换。本例在第二次交付时由单个父进程顺序操作,期间没有并发写入,因此只证明受控条件下的前提检查。

若要保护共享工作区,工具需要一个能协调全部写入者的机制,例如编辑器提供的版本化应用接口,或由文件服务统一管理版本条件。只在代理之间加锁,挡不住用户编辑器绕过锁直接写入。操作系统文件锁也取决于参与者是否遵守。无法统一协调时,交付候选文件或补丁并保留冲突,通常比承诺不会覆盖任何并发修改更诚实。

隔离工作副本有助于让代理构建和检查候选,但最终落回共享目录时仍有版本前提。不能因为测试在隔离目录通过,就忽略主工作区已经变化。读取基线、候选摘要、检查结果和最终交付摘要应形成可追溯关系,接手者才能知道测试覆盖的是哪份内容。

任务权限和操作系统隔离分别落实

任务记录中的目录范围用于描述允许编辑的位置。本例运行器自己创建临时目录,并只对自己生成的路径执行操作,没有接收不可信路径,也没有实现通用路径授权器。因此它不能证明对符号链接替换、路径穿越或恶意子进程已经提供防护。

真实执行器在工具调用前需要校验操作目标,但字符串前缀比较不足以成为边界。路径解析、目录权限、符号链接及检查后替换都可能改变实际访问对象。若运行模型生成的任意程序,还应使用与风险相称的文件系统、进程和网络隔离,让工具即使收到危险参数也不能越过操作系统限制。

权限策略应由可信任务请求和执行环境保存。网页、仓库注释和工具输出可以提供业务信息,不能自己扩大写入范围或授权对外发布。模型可以提出后续动作,执行器仍需根据动作类型和目标校验。这样接管进程读取到恶意文本时,也不会把它当成新的任务契约。

子进程环境同样属于权限面。实验工作进程只继承 PATH,没有复制生产令牌或应用数据库变量。正式构建可能确实需要更多变量,应按用途提供,并审查日志是否会记录它们。完整原始证据在假数据实验里便于审查,真实用户任务则要兼顾脱敏、访问控制和保留期限,不能无条件复制所有工具输出。

执行器还要限制运行时间和输出量。一个有权限读取文件的命令也可能挂起或打印大量数据,影响后续动作调度。这里 HTTP 客户端有超时,父进程能观察子进程退出,但没有实现全任务预算和通用进程树回收。资源管理是否完备,需要独立实验,不能由两条恢复日志代替。

接管规则要能抵抗重复执行

生产调度器可能同时启动两个接管者。它们读到同一条派发记录,各自查询并更新任务,甚至分别启动下一步。动作状态表因此通常需要版本条件或租约,确保某一时刻只有一个有效执行者继续推进;跨进程副作用还要依靠远端协议保护。本例父进程串行启动子进程,没有模拟双接管竞争。

租约到期也不能证明旧进程已经停止。暂停的旧执行者恢复后,仍可能继续发送请求。若共享服务支持单调递增的执行代号,可由服务拒绝旧代号的写入;若不支持,任务设计就要缩小需要这种保证的动作范围。单靠本地“当前拥有者”字段无法约束已经发到外部系统的请求。

检查制品时也要保存版本。测试通过后代理又改了文件,原来的测试报告不能继续覆盖新摘要。模型上下文压缩可以省略推理细节,但任务限制、待查动作和制品摘要应留在持久化记录里。接管者应从这些记录生成新的上下文,不能把摘要中的“准备验证”提升成“验证通过”。

修订后的断言覆盖十一种回执边界及本地完整流程。超时、强制终止和正常发送共用三个动作键,各只产生一次效果;正常键的异参发送记录冲突,异参恢复查询保留结果未知。文件内容断言确认最终仍有用户行,并加入候选行。任意文件编码、权限位和多文件事务不在这组样例中。

实际系统可以为不同任务设置不同验收条件。修复代码需要证明相关行为改变;生成报告需要核对来源和内容;外部发布需要提交回执。执行器应把这些条件与具体制品关联,避免只按工具退出码统计任务成功。退出码零最多说明该进程按自己的约定结束,业务结果仍要读取对应证据。

两个中断动作都查询到有效回执;新增正常动作确认成功后,同键异参没有重复登记,也没有取得虚假的成功状态。用户修改在文件冲突后保留下来。尚未解决的是服务自身重启、双接管竞争和摘要检查后的并发写入窗口。把这些边界留在技术记录中,下一位实现者就能从已证明的路径继续扩展,而不必猜测一份“恢复成功”的报告究竟覆盖了什么。

完整运行器与执行方法

以下 lab.mjs 包含父进程、工作进程、假服务和边界断言。使用 Node v25.5.0 在独立目录运行,无需安装依赖。程序创建临时工作目录,把原始日志、回执及制品保存在程序所在目录的 after/run-时间戳下;每轮使用新目录,旧结果不覆盖。

node --version
node lab.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import crypto from 'node:crypto';
import assert from 'node:assert/strict';
import {spawn} from 'node:child_process';
import {fileURLToPath} from 'node:url';
const self = fileURLToPath(import.meta.url);
const hash = x => crypto.createHash('sha256').update(x).digest('hex');
function durable(file, obj) {
  const fd = fs.openSync(file, 'a');
  try { fs.writeSync(fd, JSON.stringify(obj) + '\n'); fs.fsyncSync(fd); }
  finally { fs.closeSync(fd); }
}
function validReceipt(value, key, digest) {
  return value !== null && typeof value === 'object' && !Array.isArray(value)
    && value.key === key && value.digest === digest && value.state === 'committed'
    && typeof value.receipt === 'string' && value.receipt.trim().length > 0;
}
async function confirm(response, key, digest) {
  // This fake service defines 409 as a fingerprint conflict, never as completion.
  if (response.status === 409) return {state:'conflict',status:409};
  if (![200,201].includes(response.status)) return {state:'outcome_unknown',status:response.status};
  try {
    const value = await response.json();
    return validReceipt(value,key,digest)
      ? {state:'succeeded',status:response.status,result:value}
      : {state:'outcome_unknown',status:response.status,error:'invalid_receipt'};
  } catch (e) { return {state:'outcome_unknown',error:'receipt_read_failed',detail:e.name}; }
}
const rows = file => fs.readFileSync(file,'utf8').trim().split('\n').map(JSON.parse);
if (process.argv[2] === 'worker') {
  const [,,,dir,base,key,phase,body='artifact-v1'] = process.argv;
  const log = path.join(dir,key+'.jsonl');
  let digest;
  if (phase === 'recover') {
    const prior = rows(log);
    digest = prior.findLast(x=>x.state==='authorized').argsDigest;
    durable(log,{state:'reconciling',from:prior.at(-1).state,key,argsDigest:digest});
  } else {
    digest = hash(body);
    durable(log,{state:'authorized',key,argsDigest:digest});
    durable(log,{state:'dispatched',key,argsDigest:digest});
  }
  try {
    const response = await fetch(base+'/operations/'+key,{
      ...(phase==='recover' ? {} : {method:'POST',body}),
      signal:AbortSignal.timeout(500)
    });
    durable(log,{key,argsDigest:digest,...await confirm(response,key,digest)});
  } catch(e) { durable(log,{state:'outcome_unknown',key,argsDigest:digest,error:e.name}); }
  process.exit(0);
}
const evidence = path.join(path.dirname(self),'after','run-'+Date.now());
fs.mkdirSync(evidence,{recursive:true});
const dir = fs.mkdtempSync(path.join(os.tmpdir(),'logz-harness-'));
const events = path.join(dir,'service.jsonl'), store = new Map(), report = [];
const key='probe', digest=hash('artifact-v1');
const good={key,digest,state:'committed',receipt:'receipt-probe'};
const tests=[
  ['409',409,'{}','conflict'],['500',500,'{}','outcome_unknown'],
  ['202',202,JSON.stringify(good),'outcome_unknown'],
  ['malformed',200,'{','outcome_unknown'],['null',200,'null','outcome_unknown'],
  ['wrong-key',200,JSON.stringify({...good,key:'other'}),'outcome_unknown'],
  ['wrong-digest',200,JSON.stringify({...good,digest:'other'}),'outcome_unknown'],
  ['missing-receipt',200,JSON.stringify({...good,receipt:''}),'outcome_unknown'],
  ['pending',200,JSON.stringify({...good,state:'pending'}),'outcome_unknown'],
  ['valid',200,JSON.stringify(good),'succeeded']
];
for(const [name,status,body,want] of tests) {
  const actual=await confirm(new Response(body,{status}),key,digest);
  assert.equal(actual.state,want); report.push({case:name,actual,passed:true});
}
const broken=await confirm({status:200,json:async()=>{throw Error('body interrupted');}},key,digest);
assert.equal(broken.state,'outcome_unknown');report.push({case:'body-interrupted',actual:broken,passed:true});
let child;
const server=http.createServer(async(req,res)=>{
  const op=req.url.split('/').at(-1);
  res.setHeader('content-type','application/json');
  if(req.method==='POST') {
    let body='';for await(const b of req)body+=b;
    const old=store.get(op);
    if(old&&old.digest!==hash(body)){res.writeHead(409);res.end('{"error":"fingerprint_conflict"}');return;}
    if(!old){const value={key:op,digest:hash(body),state:'committed',receipt:'receipt-'+op,effects:1};durable(events,value);store.set(op,value);}
    if(op==='killed'){setTimeout(()=>child.kill('SIGKILL'),15);return;}
    if(op==='timeout')return; // Store the effect; deliberately withhold the response.
    res.end(JSON.stringify(store.get(op)));
  } else {
    const value=store.get(op);res.writeHead(value?200:404);res.end(JSON.stringify(value||{unknown:true}));
  }
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
const base='http://127.0.0.1:'+server.address().port;
async function worker(op,phase,body='artifact-v1') {
  const result=await new Promise((resolve,reject)=>{
    child=spawn(process.execPath,[self,'worker',dir,base,op,phase,body],{env:{PATH:process.env.PATH},stdio:['ignore','pipe','pipe']});
    let output='';child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>output+=b);
    child.on('error',reject);child.on('exit',(code,signal)=>resolve({op,phase,code,signal,output}));
  });
  report.push(result);
  if(op==='killed'&&phase==='send')assert.equal(result.signal,'SIGKILL');
  else assert.equal(result.code,0);
}
try {
  for(const op of ['timeout','killed']){
    await worker(op,'send');await worker(op,'recover');
    assert.equal(rows(path.join(dir,op+'.jsonl')).at(-1).state,'succeeded');
  }
  await worker('normal','send');assert.equal(rows(path.join(dir,'normal.jsonl')).at(-1).state,'succeeded');
  await worker('normal','send','different-intent');
  assert.equal(rows(path.join(dir,'normal.jsonl')).at(-1).state,'conflict');
  await worker('normal','recover'); // Query returns the old intent's receipt: do not accept it.
  assert.equal(rows(path.join(dir,'normal.jsonl')).at(-1).state,'outcome_unknown');
  const target=path.join(dir,'article.txt');fs.writeFileSync(target,'original\n');
  const before=hash(fs.readFileSync(target));
  fs.writeFileSync(path.join(dir,'task.json'),JSON.stringify({taskId:'edit-and-publish',writeScope:dir,artifact:{path:'article.txt',inputDigest:before},externalKeys:['timeout','killed','normal']},null,2));
  fs.writeFileSync(target,'user edit\n');const current=hash(fs.readFileSync(target));
  assert.notEqual(current,before);
  durable(path.join(dir,'files.jsonl'),{state:'conflict',expected:before,actual:current,preserved:fs.readFileSync(target,'utf8')});
  const candidate='user edit\nagent addition\n';fs.writeFileSync(path.join(dir,'candidate.txt'),candidate);
  // Single-writer handoff only; this check and rename are not atomic CAS.
  assert.equal(hash(fs.readFileSync(target)),current);fs.renameSync(path.join(dir,'candidate.txt'),target);
  durable(path.join(dir,'files.jsonl'),{state:'applied',inputDigest:current,outputDigest:hash(fs.readFileSync(target))});
  assert.equal(fs.readFileSync(target,'utf8'),candidate);
  assert.equal(store.size,3);assert([...store.values()].every(x=>x.effects===1));
  report.push({assertions:'passed',effects:[...store.values()],workdir:dir,evidence,node:process.version});
} finally {
  fs.writeFileSync(path.join(dir,'report.json'),JSON.stringify(report,null,2));
  for(const name of fs.readdirSync(dir))fs.copyFileSync(path.join(dir,name),path.join(evidence,name));
  server.closeAllConnections();await new Promise(r=>server.close(r));
}
console.log(JSON.stringify(report,null,2));