批量设备任务显示 COMPLETED,为什么还有设备失败?
用户对语音 Agent 说:“把会议室三盏灯都关掉。”后台批量任务显示 COMPLETED,Agent 回答“都关好了”,现场却还有一盏亮着。这是教学案例,没有真实灯具或 AWS 账号实测。它暴露的工程问题是:批次结束、设备上报成功、物理状态已核实,是三件不同的事。
AWS IoT Jobs 生命周期文档给出一个明确反例:对 snapshot job,所有 job execution 到达终态后,整体 job 可转为 COMPLETED;这些终态可以包含 FAILED、TIMED_OUT、REMOVED 和 CANCELED,并非全是 SUCCEEDED。因此,读到整体 COMPLETED,只能先说“这批执行已收尾”,不能向用户承诺“每台设备都成功”。这里讨论的是 AWS IoT Jobs,不是另一套 AWS IoT Commands API。Jobs 文档将 execution 的 TIMED_OUT 列为终态;不能套用 Commands 云端超时可被晚到回报覆盖的规则。文档于 2026 年 10 月 13 日再次核实,正文的整体收尾结论只限定在 snapshot job。
先从批次状态下钻到每台设备
AWS 将一个 job 与每个目标设备上的 job execution 分开。CLI 文档中的 ListJobExecutionsForJob 可列出设备及其 execution 状态;需要定位一台设备时,用 DescribeJobExecution 查其详情。JobProcessDetails API还给出成功、失败、超时、取消等分类计数。计数适合发现异常,逐设备记录才能回答“究竟哪一台需要处理”。列表有分页时须遍历全部页。
假设本次目标清单在下发前固定为 A、B、C 三台灯;最终 A 上报 SUCCEEDED,B 上报 FAILED,C 记录为 TIMED_OUT。三个 execution 都可进入终态,整体 job 因而显示 COMPLETED。Agent 的第一句回复应是:“任务批次已结束;A 上报成功,B 上报失败,C 超时。三台灯的实际状态仍待核实。”A/B/C 是教学演算,不是平台运行记录。
SUCCEEDED 也有证据边界。AWS 设备工作流文档描述设备完成操作后调用 UpdateJobExecution 报告成功。平台接收的是设备上报的执行状态;如果业务要确认灯是否真的熄灭,还需要可信的设备状态或传感回报。没有这种证据,就不能把 A 的 SUCCEEDED 扩写为“灯已经关好”。
计数能发现问题,设备清单才能定位问题
设想平台给出成功一台、失败一台、超时一台,合计三台。这个计数说明有异常,却还不能告诉用户该去哪个房间,也不能指导下一次只重试哪盏灯。必须把计数与固定目标清单和逐设备 execution 对齐,确认 A、B、C 都有结果,没有重复身份或漏项。计数相等只是一个机械检查,不是动作验收。
列表若有分页,第二页可能正好包含失败设备。读取第一页就宣布全部成功,不只是省略信息,而是改变了目标集合。查询过程保存是否完整、每页返回的查询边界、最终覆盖的设备集合;遇到分页失败或权限限制,把缺失项明确列为未查询,不能当作没有异常。这里没有规定 API 的分页实现,只要求按当前接口完整遍历。
平台汇总与逐设备记录也可能不是同一时刻的快照。查询过程中一台设备回报改变,计数与列表暂时不一致,先保留各次查询时间和原始结果,再按接口实际能力复查。不要为让表格合计一致而补造某个状态。应用结果应注明观察时间,而不是暗示所有设备在同一物理瞬间被确认。
目标设备自身也需要稳定身份。用户说会议室灯,设备显示名相似或后来改名时,查询键不应只靠名称字符串匹配。下发时确定具体设备身份与用户可读名称,结果回传按身份关联,再给用户可辨认的说明。设备组里新增或移除成员,也不能静默改写这次已有批次的目标。
若某台目标根本没有对应 execution,应用要保留“未创建执行”这一类事实。它可能源于提交失败、权限、能力不匹配或查询不完整,不能直接混成设备执行失败。先核对提交阶段与查询覆盖,才能决定下一步补提交还是补证据。批次结束并不会自动让这些遗漏消失。
把一次用户意图拆成逐设备结果
应用侧可以保存一份逐设备记录:batch_id、下发时的目标设备集合、每台的 execution 查询键、期望动作、截止时间、最后一次平台上报,以及最后一次可信物理状态。这是作者提出的系统设计,不是 AWS Jobs 自动替 Agent 维护的账本。目标集合必须在下发时确定;事后重新查询动态设备组,可能把新加入的设备算进去,也可能遗漏当时的目标。
| 设备 | 当前证据 | 此刻可说什么 | 恢复前还要查什么 |
|---|---|---|---|
| A | execution 上报 SUCCEEDED,无独立物理证据 | “A 已上报成功,灯状态待核实” | 查询可信状态;不盲目重发 |
| B | execution 上报 FAILED | “B 报告执行失败,实际状态待核实” | 失败阶段、旧执行是否还会动作、设备现状 |
| C | execution 为 TIMED_OUT | “C 超时,实际状态待核实” | 设备实际是否动作、是否能读取当前状态 |
聚合话术要按用户目标的证据计算。三台都有可信结果证明灯已关闭,才说“全部完成”;A 已核实关闭、B 已核实未关闭,才说“部分完成”;还有无法核实的设备,就点名未知项。一个业务目标即使必须“三台都关”,失败时也不能抹掉某台已经上报或已核实的事实,否则恢复流程会再作用到它。
业务结果不等于平台状态的翻译
可以把每台结果分成几列:目标是什么、平台最近上报是什么、可信状态观察是什么、当前是否满足目标、旧执行是否还可能动作。平台状态照原样保留,业务判断基于这些列生成。这样一台上报失败但当前已关灯的设备,可以报告目标当前满足、执行失败仍需诊断,而不是强行改成成功。
同样,一台上报成功却仍报告开灯,要保留冲突并查来源。成功可能描述软件流程完成,状态可能来自旧采样,也可能确实存在设备故障。没有补到关联与新鲜度证据前,不能让某个字段覆盖另一个字段。这个反例是待验证设计,不声称任何厂商实现一定存在这种问题。
聚合时,先问用户目标是否要求全体达成。“三盏都关”是全部设备当前状态满足才算达成;若目标是“尽量关闭能连接的灯”,产品可以允许部分完成,但仍需要说明无法确认的设备。成功标准应在下发前确定,不能在看到失败后临时改成“大多数成功也算完成”。
未知也有不同来源。未查询、查询失败、设备超时、状态过旧和执行与状态冲突,都可以导致暂时不能确认,但对应下一步不同。用户第一句反馈无需列尽所有内部类别,可以点名仍未确认的设备;后台保留具体原因,恢复时再选择查询、等待、诊断或人工观察。
还要区分历史批次与当前状态。批次昨天已经收尾,今天灯可能被手动打开;历史执行成功不能证明当前仍关闭。用户追问“现在都关了吗”,应重新获得当前观察,而不是复述历史 COMPLETED。若本次目标只要求在某时刻完成,也应说明确认发生的时间,避免状态持续性被暗中承诺。
恢复按设备做,不重跑整批
B 报告失败,不等于可以把整批再次执行。A 可能已经关灯,重发会造成重复动作;C 超时不能证明设备此前没有动作,也不能据此认定当前物理状态。先查 B、C 的具体 execution 和可信设备状态;只有确认旧执行以后不会再动作、该设备未达目标,才结合动作风险决定是否新建执行。对状态未知的设备,先报告未知并暂停自动补发。
若业务要求“要么都关,要么都不关”,并且已核实 A 关灯、B 无法关灯,想把 A 再打开属于新的设备动作,可能失败;它不是数据库事务式自动回滚。灯具可以由用户选择接受部分结果或人工处理;门禁、电机等高风险设备应在结果未核实前停下自动补发和自动补偿。
复制运行:分别算“收尾”与“达成目标”
下面用标准库把上述聚合规则做成可运行例子。输入是构造的三设备快照,不是 AWS API 响应;目标为 A、B、C 全部关闭。下发时固定的设备与 execution 对应关系放在 TARGETS,函数不重新查询动态设备组,新增设备 D 不会进入原目标的分母。这里的 execution 字符串是教学用查询关联键,不声称 AWS Jobs 存在同名的通用字段。
reports 保存平台执行记录,observations 单独保存模拟物理观察。只有批次、执行键、声明的可信来源与时间窗都符合本例约定,观察才可用于判断灯是否已关闭。时间使用整数演算:当前为 100,接受 90 到 100 的观察;这个十单位窗口只是例子中的业务输入条件,不是厂商推荐阈值,也没有测真实设备时钟同步。
保存为 batch_result.py,使用 Python 3.8 或以上运行。仅有标准库,不用 AWS SDK,不配置账号。自检依赖 assert,不要用 python -O。
python3 batch_result.py
"""Fixed three-device snapshot demo; constructed data, no AWS or device calls."""
from copy import deepcopy
TERMINAL = {"SUCCEEDED", "FAILED", "TIMED_OUT", "REJECTED", "REMOVED", "CANCELED"}
TARGETS = {"A": "exec-A", "B": "exec-B", "C": "exec-C"}
def summarize(batch, targets, reports, observations, now=100):
if not batch or not targets:
raise ValueError("empty batch or targets")
accepted = {}
ignored = 0
for row in reports:
device = row["device"]
if (row["batch"] != batch or device not in targets
or row["execution"] != targets[device]):
ignored += 1
continue
if device in accepted:
raise ValueError("duplicate current-device report")
if row["status"] not in TERMINAL | {"QUEUED", "IN_PROGRESS"}:
raise ValueError("unknown execution status")
accepted[device] = row
met, unmet, unknown, retry, diagnose = [], [], [], [], []
for device, execution in targets.items():
report = accepted.get(device)
obs = observations.get(device)
valid = (obs is not None and obs["batch"] == batch
and obs["execution"] == execution
and obs["source"] == "trusted_sensor"
and now - 10 <= obs["time"] <= now
and type(obs["off"]) is bool)
if not valid:
unknown.append(device)
elif obs["off"]:
met.append(device)
else:
unmet.append(device)
if report and report["status"] == "SUCCEEDED":
diagnose.append(device)
elif (report and report["status"] in TERMINAL
and report.get("old_stopped") is True):
retry.append(device)
goal = ("UNKNOWN" if unknown else "ALL_CONFIRMED" if len(met) == len(targets)
else "PARTIAL" if met else "NONE_CONFIRMED")
ended = (len(accepted) == len(targets)
and all(r["status"] in TERMINAL for r in accepted.values()))
return {"execution_ended": ended, "goal": goal, "met": met, "unmet": unmet,
"unknown": unknown, "retry_candidates": retry, "diagnose": diagnose,
"reports": {d: r["status"] for d, r in accepted.items()},
"missing_reports": [d for d in targets if d not in accepted],
"ignored_reports": ignored}
def report(device, status):
return {"batch": "batch-1", "device": device, "execution": TARGETS[device],
"status": status, "old_stopped": True}
def observation(device, off):
return {"batch": "batch-1", "execution": TARGETS[device],
"source": "trusted_sensor", "time": 100, "off": off}
def main():
rows = [report("A", "SUCCEEDED"), report("B", "FAILED"), report("C", "TIMED_OUT")]
obs = {"A": observation("A", True), "B": observation("B", False)}
result = summarize("batch-1", TARGETS, rows, obs)
assert result["execution_ended"] and result["goal"] == "UNKNOWN"
assert result["retry_candidates"] == ["B"] and result["unknown"] == ["C"]
print("terminal batch: goal=UNKNOWN retry_candidates=['B'] query=['C']")
all_off = {d: observation(d, True) for d in TARGETS}
result = summarize("batch-1", TARGETS, rows, all_off)
assert result["goal"] == "ALL_CONFIRMED" and result["reports"]["B"] == "FAILED"
assert result["retry_candidates"] == []
print("current goal met: ALL_CONFIRMED B_report=FAILED retry_candidates=[]")
mixed = deepcopy(all_off)
mixed["B"]["off"] = False
result = summarize("batch-1", TARGETS, rows, mixed)
assert result["goal"] == "PARTIAL" and result["retry_candidates"] == ["B"]
print("partial observation: met=['A', 'C'] unmet=['B']")
blocked = deepcopy(rows)
blocked[1]["old_stopped"] = False
assert summarize("batch-1", TARGETS, blocked, mixed)["retry_candidates"] == []
print("old execution not stopped: retry_candidates=[]")
success = [report(d, "SUCCEEDED") for d in TARGETS]
assert summarize("batch-1", TARGETS, success, obs)["goal"] == "UNKNOWN"
print("all reported success/missing C observation: UNKNOWN")
wrong = report("C", "SUCCEEDED")
wrong["batch"] = "batch-old"
missing = summarize("batch-1", TARGETS, rows[:2] + [wrong], obs)
assert missing["missing_reports"] == ["C"] and not missing["execution_ended"]
print("old batch excluded: missing_reports=['C'] execution_ended=False")
wrong["batch"] = "batch-1"
wrong["execution"] = "exec-C-old"
assert summarize("batch-1", TARGETS, rows[:2] + [wrong], obs)["missing_reports"] == ["C"]
extra = dict(report("A", "SUCCEEDED"), device="D", execution="exec-D")
assert summarize("batch-1", TARGETS, rows + [extra], obs)["ignored_reports"] == 1
print("wrong execution and extra D: excluded from fixed targets")
try:
summarize("batch-1", TARGETS, rows + [rows[0]], obs)
except ValueError:
pass
else:
raise AssertionError("duplicate report accepted")
print("duplicate current-device report: REJECTED")
stale = deepcopy(all_off)
stale["C"]["time"] = 89
assert summarize("batch-1", TARGETS, rows, stale)["unknown"] == ["C"]
stale["C"]["time"] = 101
assert summarize("batch-1", TARGETS, rows, stale)["unknown"] == ["C"]
stale["C"]["time"] = 100
stale["C"]["source"] = "device_claim"
assert summarize("batch-1", TARGETS, rows, stale)["unknown"] == ["C"]
print("stale/future/untrusted observation: C=UNKNOWN")
conflict = summarize("batch-1", TARGETS, success, mixed)
assert conflict["diagnose"] == ["B"] and conflict["retry_candidates"] == []
print("SUCCEEDED but observed on: diagnose=['B'] retry_candidates=[]")
print("PASS: fixed-target and evidence assertions")
if __name__ == "__main__":
main()
2026 年 10 月 13 日本地实际运行,退出码 0,标准输出为:
terminal batch: goal=UNKNOWN retry_candidates=['B'] query=['C']
current goal met: ALL_CONFIRMED B_report=FAILED retry_candidates=[]
partial observation: met=['A', 'C'] unmet=['B']
old execution not stopped: retry_candidates=[]
all reported success/missing C observation: UNKNOWN
old batch excluded: missing_reports=['C'] execution_ended=False
wrong execution and extra D: excluded from fixed targets
duplicate current-device report: REJECTED
stale/future/untrusted observation: C=UNKNOWN
SUCCEEDED but observed on: diagnose=['B'] retry_candidates=[]
PASS: fixed-target and evidence assertions
第一行把三种终态放在同一批次:A 成功、B 失败、C 超时,execution_ended 为真;但 A 的观察为已关,B 为仍开,C 没有物理观察,因此业务结果是 UNKNOWN。不是多数成功就说“完成”,也不是已有失败就抹掉未知。B 进入恢复候选,C 进入查询集合,二者的动作不同。
第二行给三台都补上当前已关的模拟观察,业务结果变成 ALL_CONFIRMED;B 的原 FAILED 仍保留,恢复候选为空。这表达的是当前关闭目标已满足、B 的历史执行仍报告失败,并不把 B 的执行状态改成成功。平台失败可能需要诊断,但不需要为了让历史字段变绿而再向已达目标的设备发动作。反过来,这些构造观察也不证明设备曾完成哪段执行流程,只判定输入中当前目标状态。
第三行把 B 的观察改为仍开,A 和 C 保持已关;目标结果是 PARTIAL,未达成名单只有 B。与第一行相比,变化仅是 C 从缺证据变成已核实;未知消失后,才可以用“部分达成”完整描述这组输入。用户能据此判断剩下哪台需要处理,而不必猜部分成功是否还藏着离线设备。
恢复候选不是自动重发命令
代码中的 old_stopped 是独立给入的构造证据,表示旧执行此后不会再启动或继续动作。它不是读取到终态后自动设置的标志,也不是 AWS API 的同名保证。把 B 的这个字段改成假,仍然能知道 B 当前没关,却不再产生恢复候选;代码用这一反例检查“目标未达成”与“现在可以再次作用设备”是两个判断。
即便 B 出现在 retry_candidates,本例也没有发送请求。实际恢复还要检查当前用户意图、动作风险、有效期与设备协议能力,再决定是否建立新的执行。对 C,缺少物理结果就先查;对 A,目标已满足就保留原结果。聚合函数交付的是有理由的候选集合,不能直接拿它驱动所有设备的自动重试。
当平台报告 SUCCEEDED 而可信模拟观察为仍开,本例把 B 放入 diagnose,不放入恢复候选。当前目标没有达成已经可见,但执行与观察为何冲突仍不清楚。可能需要核对观察与执行的关联、设备上报语义,或是否有后续操作;保留两列原始证据比选择一个字段覆盖另一列更利于排查。这里展示输入冲突的应用处理,不指称 AWS 已发生过这种故障。
漏项与重复,先挡在聚合入口
旧批次中 C 的成功记录不会填补本批次 C 的缺失;旧 execution 键同样排除。函数把它们计入 ignored_reports,原清单中的 C 继续出现在 missing_reports,execution_ended 为假。这一保守覆盖结果不是要改写云端 job 状态;它表示应用尚未完整覆盖此次授权目标,即使外部另一个字段显示完成,也不能对用户宣布全体受控设备已核实。
当前批次同一设备重复出现,直接拒绝输入,不用最后一条覆盖前一条。输入合同是“一次完整读取后,每个目标至多一条当前执行快照”,不是原始事件流。若采集层拿到分页重叠、重复消息或多个版本,应先按实际查询契约整理并保存原记录,再调用本函数;有冲突而无可信顺序时保留待核实,不能为了凑齐 A、B、C 擅自选一条成功。
设备 D 的记录则排除在原清单外,固定分母没有扩大。用户后来要求把 D 也关掉,应建立新的目标授权;组成员增删不应改写历史批次。例子没有实现分页或重命名解析,但缺 C、重复 A、额外 D 和旧键这几组输入,已经能让本地聚合暴露常见的覆盖错误。实际接入仍须验证全页读取和稳定设备身份映射。
旧时间 89、未来时间 101 和非可信来源 device_claim 三种观察都让 C 保持 UNKNOWN。trusted_sensor 只是输入合同中的标签,函数没有认证传感器,也不会替数据来源背书。真实系统要在采集层确认来源可信、单位正确、观察时间有意义;否则把普通设备自报贴上这个标签,就会再次把“宣称成功”伪装成独立物理证据。
本例接收字段完整、已经归一化的快照;不解析 MQTT、不处理原始 JSON 或事件乱序,不实现分布式事务和批次取消。它验证固定目标覆盖、证据分层、汇总与恢复候选的有限规则。下一步真机测试应保存目标、原始回报、可信观察及时间,再比较实际反馈和动作路径;本地 stdout 只能证明这组构造输入跑出了这些分支,不能作为三盏真灯已关闭的验收。
恢复计划是一组新的逐设备决定
若 B 可以恢复,新的执行与旧执行建立关联,并保留恢复原因、授权和目标。如果此时用户改口不再要求关闭,就停止尚未提交的恢复,已提交的动作按真实取消能力处理。恢复计划不是后台永远重复的队列,它依赖当前有效意图和设备证据。
即便关闭属于目标状态设置,重跑整批也会增加网络和设备负担,并可能覆盖后续意图。对于相对动作或一次性动作,风险更直接:已成功设备可能被再次触发。所以恢复集合来自逐设备判断,不是简单选所有 FAILED 或所有非 SUCCEEDED。状态名提供线索,实际是否可重做仍需动作语义与设备终态。
验收要覆盖遗漏,而不只覆盖失败
最后模拟晚到回报、恢复执行和用户新意图交错到达。旧批次的迟到成功应更新它对应的历史记录;新的设备状态按新鲜度合并;用户当前要求决定是否继续恢复。若最终只保留最后一条消息,系统很容易把旧结果当成新动作的证明。逐设备时间线能暴露这种错配。
测试产物至少包含固定目标、每台原始状态、观察来源与时间、聚合文案和恢复集合。模拟中的物理状态只是测试输入,不能作为真实设备成功证明。真机接入后再核实际动作和报告可信度,将两类证据明确分开。
给用户一份能继续处理的结果
批量结果首先要对应用户可识别的设备。可以先说“三台执行已收尾”,再逐项说明“门口灯已核实关闭,投影旁灯执行失败,窗边灯暂未确认”。设备名字来自下发时的对应关系,详细接口键留在后台。用户知道具体位置后才能检查或决定是否补做;只有数字的“成功一台、失败一台”不够完成交付。
如果设备很多,不需要把全部正常项逐条念完,但不能隐藏异常名单和未知数量。可以汇总已确认达成数量,点出需要用户处理的设备,并提供完整记录。对于语音交互,先讲主要结果,再回答追问;后台仍保留所有目标与证据,简短表达不等于简化判定。
还要说明结果观察时间。状态回报已经很旧时,可说某台上次报告关闭、当前待确认;实时查询成功时,说当前设备报告关闭。两种反馈都准确,只是保证不同。若产品对时间有严格要求,按目标使用合适的观察来源,不把周期旧值升级成实时物理事实。
恢复完成后,原批次与恢复批次分别保留。用户可以得到“原来一台失败,后来对该台补做已确认”的结论;不能把原记录改成三台一开始都成功。历史事实帮助之后定位故障频率,也能解释为什么某台收到多次指令。新恢复失败则同样点名未完成项,不以批次结束掩盖它。
等待边界由产品确定,不能为了得到全体成功无限等待。到了边界,有结果的设备保留结果,没结果的设备保持未知,并停止不安全的自动重发。如果用户要求继续查询,就沿原身份获取后续证据;如果用户要求取消剩余任务,就按设备所处阶段处理。完成聚合、继续等待和取消剩余动作是不同选择。
把这份最小例接到业务时,先确定哪一层提供完整目标快照、哪一层负责物理证据,再据逐设备结果表达达成与未知。批次收尾帮助停止等待,证据覆盖才让用户知道哪台可以放心、哪台还要查询或处理。