From efbc5f6e270ff002457aa2ced6f274b12780f0b4 Mon Sep 17 00:00:00 2001 From: "jj.wang" Date: Wed, 21 Oct 2026 07:13:18 +0000 Subject: [PATCH] Core: cancel moved runs, retry bookkeeping, progress cap, safer classification - A run whose item leaves In Progress is killed, gets a [worker] cancel comment, frees its terminal slot and serialize group and keeps its thread id; if the kill fails the slot stays taken and the cancel is retried next tick. - Transient retries are the same logical round: they do not count toward max_rounds_per_thread and do not trigger a max-rounds rebuild. - New project setting max_progress_rounds (default 12): after that many consecutive [progress] rounds the item goes to Waiting (answerer) with a note. - retry/rebuild markers are consumed only after the run launched, so a failed start no longer leaves an In Progress item that is never picked again; a rebuild is no longer reported as a retry. - State is saved after every finished round and every launched run. - Failure classification: transient patterns only look at the tail, and an exit-0 failure is logic (never retried) unless the tail shows a transient error. - First-line tag: tolerate BOM and a leading code fence / rule line. - Start comment notes when a handoff summary was attached. --- agentd/core.py | 104 +++++++++++++++++++++++----- config/agentd.example.yaml | 1 + config/schema.json | 1 + tests/fakes.py | 7 ++ tests/test_core.py | 137 ++++++++++++++++++++++++++++++++++++- tests/test_prompt.py | 7 ++ tests/test_text.py | 19 +++++ 7 files changed, 258 insertions(+), 18 deletions(-) diff --git a/agentd/core.py b/agentd/core.py index 0572f75..1c1164e 100644 --- a/agentd/core.py +++ b/agentd/core.py @@ -34,11 +34,19 @@ OUTPUT_RULE = "输出要求:最后一条消息首行为 [review]/[question]/[b # ---------------------------------------------------------------- pure helpers (unit-tested) +FENCE = re.compile(r"^(```|~~~|-{3,}$|\*{3,}$)") + + def classify_first_line(text): - """Tag of the first non-empty line (`[review]` etc.), tolerating markdown emphasis; None if untagged.""" - first = next((l.strip() for l in (text or "").splitlines() if l.strip()), "") - m = re.match(r"\[(\w+)\]", first.lstrip("*_#> `").lower()) - return m.group(1) if m and m.group(1) in TAGS else None + """Tag of the first meaningful line (`[review]` etc.), tolerating a BOM, markdown emphasis and a leading code + fence / rule line; None if untagged. Trailing text after the tag is fine (`[progress] half way`).""" + for line in (text or "").splitlines(): + first = line.strip().lstrip("\ufeff").strip() + if not first or FENCE.match(first): + continue + m = re.match(r"\[(\w+)\]", first.lstrip("*_#> `").lower()) + return m.group(1) if m and m.group(1) in TAGS else None + return None def matches_any(text, patterns): @@ -46,14 +54,18 @@ def matches_any(text, patterns): def classify_failure(result, timed_out, retry_cfg): - """-> (kind, transient). kind in timeout | env | crash | logic (design §4).""" + """-> (kind, transient). kind in timeout | env | crash | logic (design §4). + env patterns look at the tail and the last message; transient patterns only at the tail (stderr / last event), + so an agent's own prose ("tests timed out") never triggers a retry. logic (exit 0, no message) is never + transient: an exit-0 run whose tail shows a transient error is a crash.""" if timed_out: return "timeout", False - text = "%s\n%s" % (result.tail or "", result.last_message or "") - if matches_any(text, retry_cfg.get("env_patterns", [])): + if matches_any("%s\n%s" % (result.tail or "", result.last_message or ""), retry_cfg.get("env_patterns", [])): return "env", False - kind = "logic" if result.exit_code == 0 else "crash" - return kind, matches_any(text, retry_cfg.get("transient_patterns", DEFAULT_TRANSIENT)) + transient = matches_any(result.tail or "", retry_cfg.get("transient_patterns", DEFAULT_TRANSIENT)) + if result.exit_code == 0 and not transient: + return "logic", False + return "crash", transient def parse_budget(label): @@ -209,6 +221,7 @@ class Agentd: self.poll(ctx, data, iid, rec) except Exception as exc: self.log("%s poll error: %r" % (ctx.key(rec.get("seq")), exc)) + self.store.save(data) # persist each round's outcome (thread id, retry) before touching the next self.pick(ctx, data, pstate, only_issue) # ---------------------------------------------------------------- (a) poll / finish @@ -216,6 +229,9 @@ class Agentd: fl = rec["inflight"] ex = self.executor(fl["terminal"], fl["executor"]) if ex.poll(fl["handle"]) == "running": + issue = ctx.issues.get(iid) + if issue is not None and issue.get("state") != ctx.state_id("running"): + return self.cancel(ctx, iid, rec, ex, issue) if self.now() < fl["deadline"]: return self.log("%s run %s exceeded its budget; killing" % (ctx.key(rec["seq"]), fl["run_id"])) @@ -232,6 +248,37 @@ class Agentd: ex.forget(fl["handle"]) self.finish(ctx, data, iid, rec, result) + def cancel(self, ctx, iid, rec, ex, issue): + """The item left In Progress while its run was still going (moved by a person): kill the run, say so, free + the terminal slot and serialize group. The thread id is kept so a later round can resume it.""" + fl = rec["inflight"] + key = ctx.key(rec["seq"]) + try: + ex.kill(fl["handle"]) + except Exception as exc: # keep the slot until the kill succeeds; a live run must not share the terminal + self.log("%s cancel of run %s: kill failed, retrying next tick: %r" % (key, fl["run_id"], exc)) + return + if hasattr(ex, "forget"): + self.guarded(key, ex.forget, fl["handle"]) + thread = fl["handle"].get("thread_id") + if not thread and ex.resume: + try: # a fresh run may already have announced its thread (codex thread.started) + thread = ex.collect(fl["handle"]).thread_id + except Exception: + thread = None + state = {v: k for k, v in ctx.states.items()}.get(issue.get("state"), issue.get("state")) + try: + self.post(ctx, self.client(fl["terminal"]), iid, rec, + "%s 运行已取消:工作项状态被改为 %s(run=%s)。" % (WORKER, state, fl["run_id"])) + except Exception as exc: + self.log("%s cancel comment failed: %r" % (key, exc)) + rec.pop("inflight") + rec.pop("retry", None) + if thread: + rec["thread_id"], rec["thread_on"] = thread, [fl["terminal"], fl["executor"]] + rec["last_tag"] = "cancelled" + self.log("%s run %s cancelled: item moved to %s" % (key, fl["run_id"], state)) + def finish(self, ctx, data, iid, rec, result, timed_out=False): """Post first, then forget the in-flight run: if Plane fails, the next tick collects again.""" fl = rec["inflight"] @@ -258,7 +305,16 @@ class Agentd: self.close_run(rec, result) rec["tags"].append(tag) rec.update(last_message=message[:4000], last_tag=tag, retries=0) - if still_ours: + rec["progress_streak"] = rec.get("progress_streak", 0) + 1 if tag == "progress" else 0 + cap = ctx.cfg.get("max_progress_rounds", 12) + if still_ours and tag == "progress" and rec["progress_streak"] >= cap: + who = ctx.cfg.get("answerer") or ctx.cfg["reviewer"] + rec["progress_streak"] = 0 + self.guarded(key, self.post, ctx, client, iid, rec, "%s 已连续 %d 轮 [progress](上限 max_progress_rounds=%d)," + "暂停自动续跑,转 %s 并指派给 %s;移回 %s 即继续。" % ( + WORKER, cap, cap, ctx.cfg["states"]["waiting"], who, ctx.cfg["states"]["todo"])) + self.guarded(key, self.move, ctx, client, iid, "waiting", who) + elif still_ours: self.guarded(key, self.transition, ctx, client, iid, tag, self.cfg.terminals[term]["plane_user"]) else: self.log("%s was moved during the run; not changing its state" % key) @@ -317,6 +373,7 @@ class Agentd: rec["retry"] = {"at": self.now() + delay * 60, "reason": kind} elif plan == "waiting": rec["retries"] = 0 + rec["progress_streak"] = 0 if kind == "env" and fl.get("group"): data["paused_groups"][fl["group"]] = {"project": ctx.name, "issue": iid, "key": key} self.guarded(key, self.move, ctx, client, iid, "waiting", who) @@ -390,6 +447,7 @@ class Agentd: except Exception as exc: self.log("%s start failed on %s: %r" % (ctx.key(issue.get("sequence_id")), term, exc)) continue + self.store.save(data) # a launched run must be on disk before anything else can fail load[term] += 1 if group: groups.add(group) @@ -465,18 +523,19 @@ class Agentd: term, exname = route["terminal"], route["executor"] tcfg, pcfg = self.cfg.terminals[term], ctx.cfg ex = self.executor(term, exname) - is_retry = rec.pop("retry", None) is not None and issue.get("state") == ctx.state_id("running") - if not is_retry: - rec["retries"] = 0 + # retry / rebuild markers are only consumed once the run has launched: if start fails they must survive, + # otherwise an In Progress item without a run would never be picked again. + retry = rec.get("retry") + is_retry = bool(retry) and retry.get("reason") != "rebuild" and issue.get("state") == ctx.state_id("running") k = rec["runs"] + 1 run_id = "%s-%d-%d" % (ctx.prefix, seq, k) - thread, reason = rec.get("thread_id"), rec.pop("rebuild", None) + thread, reason = rec.get("thread_id"), rec.get("rebuild") max_rounds = pcfg.get("max_rounds_per_thread") or self.cfg.agentd.get("max_rounds_per_thread", 8) if thread and not ex.resume: thread = None elif thread and rec.get("thread_on") != [term, exname]: thread, reason = None, "换了终端/执行器" - elif thread and rec.get("thread_rounds", 0) >= max_rounds: + elif thread and not is_retry and rec.get("thread_rounds", 0) >= max_rounds: # a retry is the same round thread, reason = None, "线程已续 %d 轮(上限 %d)" % (rec["thread_rounds"], max_rounds) handoff = handoff_summary(rec, reason) if not thread and rec["runs"] > 0 else None retry_note = None @@ -489,8 +548,14 @@ class Agentd: prompt = build_prompt(key, issue, comments, seen, bool(thread), self.protocol(pcfg), intro, handoff, retry_note) run = Run(run_id, tcfg["repo"], prompt, route["model"], route["effort"], route["tier"], thread, route["script"]) handle = ex.start(run) + rec.pop("retry", None) + rec.pop("rebuild", None) + if not is_retry: + rec["retries"] = 0 now = self.now() - rec.update(runs=k, thread_rounds=rec.get("thread_rounds", 0) + 1 if thread else 1, inflight={ + # transient retries continue the same logical round: they do not use up max_rounds_per_thread + rounds = (rec.get("thread_rounds", 0) + (0 if is_retry else 1)) if thread else 1 + rec.update(runs=k, thread_rounds=rounds, inflight={ "run_id": run_id, "terminal": term, "executor": exname, "handle": handle, "round": k, "started": now, "deadline": now + route["timeout_hours"] * 3600, "resumed": bool(thread), "group": route["group"], "model": route["model"], "effort": route["effort"]}) @@ -500,7 +565,12 @@ class Agentd: rec["seen_comments"] = sorted(seen | {c["id"] for c in comments if c.get("id")}) rec["last_comment_at"] = max([c.get("created_at", "") for c in comments] + [rec.get("last_comment_at", "")]) client = self.client(term) - mode = ("续线程 %s" % thread[:12]) if thread else ("新线程(重建:%s)" % reason if reason else "新线程") + if thread: + mode = "续线程 %s" % thread[:12] + elif reason: + mode = "新线程(重建:%s,附交接摘要)" % reason if handoff else "新线程(重建:%s)" % reason + else: + mode = "新线程(附交接摘要)" if handoff else "新线程" if is_retry: mode += ",重试 %d" % rec.get("retries", 0) detail = " / ".join(x for x in (exname, route["model"], route["effort"], route["tier"]) if x) diff --git a/config/agentd.example.yaml b/config/agentd.example.yaml index d0db698..dac154c 100644 --- a/config/agentd.example.yaml +++ b/config/agentd.example.yaml @@ -86,4 +86,5 @@ projects: # transient_patterns / env_patterns default to the built-in lists in agentd/core.py timeout_hours: 4 max_rounds_per_thread: 8 + max_progress_rounds: 12 # after this many consecutive [progress] rounds: note + Waiting (answerer) notify: {ntfy_topic: gwent, 'on': [waiting, review, failed]} # phase 3, not used yet; 'on' must be quoted (YAML 1.1 boolean) diff --git a/config/schema.json b/config/schema.json index 9e41c4c..cac0631 100644 --- a/config/schema.json +++ b/config/schema.json @@ -131,6 +131,7 @@ }, "timeout_hours": {"type": "number", "minimum": 0}, "max_rounds_per_thread": {"type": "integer", "minimum": 1}, + "max_progress_rounds": {"type": "integer", "minimum": 1}, "notify": { "type": "object", "additionalProperties": false, diff --git a/tests/fakes.py b/tests/fakes.py index b72b9fe..3e3a8c1 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -87,8 +87,12 @@ class FakeExecutor: def __init__(self, resume=True): self.resume = resume self.started, self.killed, self.done = [], [], {} + self.fail_starts, self.fail_kills = 0, 0 def start(self, run): + if self.fail_starts: + self.fail_starts -= 1 + raise RuntimeError("relay down") self.started.append(run) return {"run_dir": "/runs/" + run.run_id, "run_id": run.run_id, "thread_id": run.thread_id} @@ -99,6 +103,9 @@ class FakeExecutor: return self.done[handle["run_id"]] def kill(self, handle): + if self.fail_kills: + self.fail_kills -= 1 + raise RuntimeError("relay down") self.killed.append(handle["run_id"]) def finish(self, message, exit_code=0, thread="th-1", tail=""): diff --git a/tests/test_core.py b/tests/test_core.py index f7262dd..74c3760 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -123,7 +123,7 @@ class CoreTest(unittest.TestCase): self.assertIn("## 交接摘要", run.prompt) self.assertIn("续线程失败", run.prompt) self.assertIn("[progress] half way", run.prompt) - self.assertIn("新线程(重建:续线程失败(crash))", self.p.texts(iid)[-1]) + self.assertIn("新线程(重建:续线程失败(crash),附交接摘要)", self.p.texts(iid)[-1]) def test_thread_rebuilt_after_max_rounds(self): iid = self.p.add_issue(1) @@ -198,6 +198,141 @@ class CoreTest(unittest.TestCase): self.assertEqual(hb["inflight"][0]["run"], "GW-1-1") self.assertEqual(hb["errors"], []) + # ---------------------------------------------------------------- reviewer decisions / fixed defects + def test_item_moved_out_of_in_progress_cancels_run(self): + a = self.p.add_issue(1, labels=["needs-game"]) + b = self.p.add_issue(2, labels=["needs-game"]) + self.h.tick() + self.codex().finish("[progress] step 1") + self.h.tick() # round 2 resumes th-1 + self.assertEqual(self.codex().started[-1].run_id, "GW-1-2") + self.p.issues[a]["state"] = "s-wait" # a person moves it while round 2 runs + self.h.tick() + self.assertEqual(self.codex().killed, ["GW-1-2"]) + self.assertIn("[worker] 运行已取消:工作项状态被改为 Waiting(run=GW-1-2)", self.p.texts(a)[-1]) + rec = self.h.rec(a) + self.assertNotIn("inflight", rec) + self.assertEqual((rec["thread_id"], rec["thread_on"]), ("th-1", ["win10", "codex"])) + self.assertEqual(self.p.state_of(a), "Waiting") # agentd does not touch the state + # terminal slot (concurrency 1) and serialize group were released in the same tick + self.assertEqual(self.codex().started[-1].run_id, "GW-2-1") + self.assertEqual(self.p.state_of(b), "In Progress") + # back to Todo later: resumes the kept thread + self.codex().finish("[review] b done") + self.p.issues[a]["state"] = "s-todo" + self.h.tick() + self.assertEqual((self.codex().started[-1].run_id, self.codex().started[-1].thread_id), ("GW-1-3", "th-1")) + + def test_cancel_keeps_slot_when_kill_fails(self): + a = self.p.add_issue(1) + self.p.add_issue(2) + self.h.tick() + self.codex().fail_kills = 1 + self.p.issues[a]["state"] = "s-bl" + self.h.tick() + self.assertIn("inflight", self.h.rec(a)) + self.assertEqual(len(self.codex().started), 1) # GW-2 must not share win10 with a live run + self.h.tick() + self.assertNotIn("inflight", self.h.rec(a)) + self.assertIn("被改为 Backlog", self.p.texts(a)[-1]) + self.assertEqual(self.codex().started[-1].run_id, "GW-2-1") + + def test_transient_retries_do_not_use_up_thread_rounds(self): + iid = self.p.add_issue(1) + self.h.tick() + self.codex().finish("[progress] 1") + self.h.tick() + self.codex().finish("[progress] 2") + self.h.tick() # round 3: thread_rounds == 3 == max + self.assertEqual(self.h.rec(iid)["thread_rounds"], 3) + self.codex().finish("", exit_code=1, tail="stream disconnected before completion") + self.h.tick() + self.h.clock[0] += 121 + self.h.tick() + retry = self.codex().started[-1] + self.assertEqual((retry.run_id, retry.thread_id), ("GW-1-4", "th-1")) # same thread, new run id + self.assertEqual(self.h.rec(iid)["thread_rounds"], 3) + self.codex().finish("[progress] 3") + self.h.tick() + self.assertIsNone(self.codex().started[-1].thread_id) # next real round rebuilds + self.assertIn("线程已续 3 轮(上限 3)", self.p.texts(iid)[-1]) + + def test_failed_start_keeps_retry_marker(self): + iid = self.p.add_issue(1) + self.h.tick() + self.codex().finish("", exit_code=1, tail="Connection reset by peer") + self.h.tick() + self.h.clock[0] += 121 + self.codex().fail_starts = 1 + self.h.tick() # relay down while relaunching + self.assertIn("retry", self.h.rec(iid)) + self.assertEqual(self.p.state_of(iid), "In Progress") + self.h.tick() + self.assertEqual(self.codex().started[-1].thread_id, "th-1") + self.assertIn("inflight", self.h.rec(iid)) + + def test_progress_cap_moves_to_waiting(self): + data = config_data() + data["projects"]["GWENT"]["max_progress_rounds"] = 2 + h = Harness(data) + iid = h.plane.add_issue(1) + h.tick() + ex = h.execs[("win10", "codex")] + ex.finish("[progress] 1") + h.tick() + self.assertEqual(len(ex.started), 2) + ex.finish("[progress] 2") + h.tick() + self.assertEqual(len(ex.started), 2) + self.assertEqual((h.plane.state_of(iid), h.plane.assignee_of(iid)), ("Waiting", "kappiee")) + self.assertIn("已连续 2 轮 [progress]", h.plane.texts(iid)[-1]) + h.plane.issues[iid].update(state="s-todo", assignees=["u-w10"]) # a person lets it continue: fresh allowance + h.tick() + ex.finish("[progress] 3") + h.tick() + self.assertEqual(len(ex.started), 4) + h.close() + + def test_tagged_message_counts_as_success_even_with_nonzero_exit(self): + iid = self.p.add_issue(1) + self.h.tick() + self.codex().finish("[review] done, but a hook failed", exit_code=1) + self.h.tick() + self.assertEqual(self.p.state_of(iid), "Review") + self.assertIn("第 1 轮结束(exit=1,", self.p.texts(iid)[-1]) + + def test_untagged_message_with_nonzero_exit_is_a_failure(self): + iid = self.p.add_issue(1) + self.h.tick() + self.codex().finish("I gave up, tests timed out", exit_code=1, tail="") + self.h.tick() + self.assertEqual(self.p.state_of(iid), "Waiting") # agent prose never makes it transient + self.assertIn("类型=crash,run=GW-1-1", self.p.texts(iid)[-1]) + + def test_rebuild_after_resume_failure_happens_once(self): + iid = self.p.add_issue(1) + self.h.tick() + self.codex().finish("[progress] half way") + self.h.tick() + self.codex().finish("", exit_code=1, thread=None, tail="thread not found") + self.h.tick() # immediate rebuild (round 3, fresh thread) + self.assertIsNone(self.codex().started[2].thread_id) + self.assertNotIn("重试", self.p.texts(iid)[-1]) + self.codex().finish("", exit_code=1, thread=None, tail="thread not found") + self.h.tick() + self.assertEqual(len(self.codex().started), 3) # no second rebuild + self.assertEqual((self.p.state_of(iid), self.p.assignee_of(iid)), ("Waiting", "kappiee")) + + def test_state_saved_right_after_each_start(self): + self.p.add_issue(1, assignee="oregon") + self.p.add_issue(2, assignee="oregon") + saves = [] + orig = self.h.d.store.save + self.h.d.store.save = lambda data: (saves.append(sorted( + r["inflight"]["run_id"] for r in data["projects"]["GWENT"]["issues"].values() if r.get("inflight"))), orig(data)) + self.h.tick() + # one save per launched run (a crash mid-tick cannot orphan it), then the end-of-tick save + self.assertEqual(saves, [["GW-1-1"], ["GW-1-1", "GW-2-1"], ["GW-1-1", "GW-2-1"]]) class EnvFailureTest(unittest.TestCase): def test_env_failure_pauses_group_until_item_leaves_waiting(self): diff --git a/tests/test_prompt.py b/tests/test_prompt.py index ce24692..a209d71 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -37,6 +37,13 @@ class PromptTest(unittest.TestCase): self.assertIn("RETRY", p) self.assertNotIn("没有新评论", p) + def test_new_comment_filter_is_by_id_not_timestamp(self): + late_but_old = [{"id": "c9", "created_at": "t0", "comment_html": "

clock-skewed reply

"}, + {"id": "c1", "created_at": "t9", "comment_html": "

edited old one

"}] + p = build_prompt("GW-1", ISSUE, late_but_old, {"c1"}, True) + self.assertIn("clock-skewed reply", p) + self.assertNotIn("edited old one", p) + def test_handoff(self): rec = {"runs": 3, "tags": ["progress", "progress", "question"], "last_message": "[question] A or B?"} h = handoff_summary(rec, "线程已续 3 轮") diff --git a/tests/test_text.py b/tests/test_text.py index f4cbffb..f24b322 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -13,6 +13,18 @@ class FirstLineTest(unittest.TestCase): self.assertEqual(classify_first_line("**[blocked]** no device"), "blocked") self.assertEqual(classify_first_line("[progress]"), "progress") + def test_edge_cases(self): + self.assertEqual(classify_first_line("\ufeff[review] ok"), "review") + self.assertEqual(classify_first_line("```\n[progress] still going\n```"), "progress") + self.assertEqual(classify_first_line("```text\n[blocked] no device\n```"), "blocked") + self.assertEqual(classify_first_line("---\n[question] which?"), "question") + self.assertEqual(classify_first_line("[progress]: step 2 of 5, next: tests"), "progress") + self.assertEqual(classify_first_line("> `[review]` merged"), "review") + self.assertIsNone(classify_first_line("note first\n[review] later")) # tag must lead + msg = html_to_text(text_to_html("[codex] 第 1 轮结束", pre="\n [review] ok\n")) + self.assertEqual(msg, "[codex] 第 1 轮结束\n\n [review] ok") #
-wrapped body keeps its lines
+        self.assertEqual(classify_first_line(html_to_text("
\n[progress] x
")), "progress") + def test_untagged(self): self.assertIsNone(classify_first_line("done, see [review] below")) self.assertIsNone(classify_first_line("[done] finished")) @@ -56,6 +68,13 @@ class FailureTest(unittest.TestCase): self.assertEqual(self.kind("whatever", timed_out=True), ("timeout", False)) self.assertEqual(self.kind("window lost focus", cfg={"env_patterns": ["lost focus"]}), ("env", False)) + def test_logic_is_never_transient_and_prose_is_ignored(self): + self.assertEqual(self.kind("HTTP 429 Too Many Requests", exit_code=0), ("crash", True)) + r = ExecResult("tests timed out", 1, None, [], "") + self.assertEqual(classify_failure(r, False, {}), ("crash", False)) + r = ExecResult("foreground window lost", 1, None, [], "") + self.assertEqual(classify_failure(r, False, {"env_patterns": ["window lost"]}), ("env", False)) + def test_custom_transient_patterns_replace_defaults(self): self.assertEqual(self.kind("timed out", cfg={"transient_patterns": ["flaky"]}), ("crash", False)) self.assertEqual(self.kind("flaky relay", cfg={"transient_patterns": ["flaky"]}), ("crash", True))