diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index dbd17f38fe..76c4b8a0bf 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -889,10 +889,21 @@ Existing negative-result and later-work followthrough coverage remains necessary The effective review cadence remains owned by the [Goal Vision/replan contract](../../reference/protocols/goal-vision-replan-contract-v0.md#default-review-cadence) and Goal/device configuration. Six effective work Turns is the default; enabling -the stride strategy for one Goal inherits its live review cadence, and only an -explicit Goal cadence override pins a separate count or unit. The cadence is not -Todo size, an experiment boundary or a mandatory route switch; this refinement -does not change counting. Matched continuation must qualify actual +the stride strategy for one Goal inherits that configured baseline. For the +enabled strategy with receipt-backed effective-Turn cadence, the latest settled +fresh stride review through the current Agent's last autonomous ACK adjusts the +next interval: `grow` with `supported_progress` and an evidence-linked previous +`goal_gain=improved` uses 1.5 times the baseline; `verify` or an uncertainty/gap/ +failure basis uses 0.5 times; `switch` takes precedence and keeps the baseline. +Round up to at least one Turn, without compounding repeated reads. An initial, +unsettled, historical-v1 or missing decision keeps the baseline; do not reach +through a newer ACK to reuse older feedback. The settled writer validates citation +provenance; the gain remains an Agent claim, not independent outcome certification. +Off mode, legacy counting, stall triggers, authority, per-Turn settlement and +frozen workers retain their behavior. The inline review questions and syntax +example remain delivered together; shortening repeated prose adds no decision +fields or gates. Cadence is not Todo size, an experiment boundary or a mandatory +route switch. Matched continuation must qualify actual adoption, target benefit and full cost before promotion or synergy claims. Exploratory iteration may run only strategy-enabled test arms and reuse historical diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md index 132b2a5762..d8085ce686 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md @@ -720,8 +720,15 @@ Todo。验收和能力 owner 显式限定的结果仍须遵守;默认关闭和 分数提升;既有负结果与后续实际工作跟进验证仍有必要。 有效复核周期仍由[Goal Vision/replan 契约](../../reference/protocols/goal-vision-replan-contract-v0.md#default-review-cadence) 及 Goal/设备配置负责。六个有效工作 Turn 是默认值;对单个 Goal 启用步幅策略时 -沿用实时复盘周期,只有显式设置 Goal 复盘周期,才固定该 Goal 的计数依据或数量。 -周期不是 Todo 大小、实验边界或强制换路信号;本细化不改变计数。 +沿用该配置基准。启用策略且按结算回执计有效 Turn 时,读取当前 Agent 最近一次 +自主 ACK 之前(含该 ACK)的最新已结算 fresh stride 复盘,调整下一周期: +`grow`、`supported_progress` 且前次 `goal_gain=improved` 有证据关联时取基准的 +1.5 倍;`verify` 或不确定/差距/失败依据取 0.5 倍;`switch` 优先并保留基准。 +向上取整且至少一个 Turn,多次读取不连乘。首次、未结算、历史 v1 或缺失决定 +保留基准;不能越过更新的 ACK 重用旧反馈。已结算写回校验引用来源,收益仍是 +Agent 声明,不是独立结果认证。关闭状态、旧计数方式、停滞触发器、权限、每 +Turn 结算和冻结 worker 行为不变。复盘问题和语法示例仍在同一 inline 包中交付; +压缩重复文字不新增决定字段或 gate。周期不是 Todo 大小、实验边界或强制换路信号。 策略采用、目标收益、完整成本和交互增益须通过匹配接续等实验验证后再作结论。 敏捷探索可只跑启用策略的测试组,以历史基线作描述性参照;不要求每次迭代都 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 4fd53e2ff2..8ec71bebbf 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -38,6 +38,16 @@ no TurnEnvelope opt-in is needed. Quota and writeback resolve the same live configuration. See [Goal review cadence](../../quota-allocation.md#goal-review-cadence) for configuration precedence, rollback and independent review triggers. +When `stride_strategy=evidence_conditioned_v1` is enabled, receipt-backed cadence +uses the configured count as a baseline for the next interval. A settled fresh +review can scale it by 1.5 (supported grow with evidence-linked improved goal +gain) or 0.5 (verify or uncertainty/gap/failure); switch keeps the baseline. +Counts round up to at least one, never compound across reads, and do not cross a +newer autonomous ACK to reuse older feedback. Missing/unsettled/historical +decisions and off mode keep the baseline. Stall triggers and settlement remain +independent. This feedback uses source-validated Agent claims, not certified gain; +see the [strategy boundary](../../architecture/rfcs/hierarchical-agent-stride-control-v0.md#76-research-scope-and-evidence-conditioned-control). + ## Replan ACK freshness An accepted ACK with a legal vision outcome covers gaps at or before its diff --git a/loopx/control_plane/work_items/replan_history.ts b/loopx/control_plane/work_items/replan_history.ts index 905d1ebcce..a97bc2684c 100644 --- a/loopx/control_plane/work_items/replan_history.ts +++ b/loopx/control_plane/work_items/replan_history.ts @@ -9,6 +9,7 @@ import { projectTodoResumePlanning } from "../todos/resume_planning.ts"; import { monitorIsActive } from "../todos/summary_lanes.ts"; import { authorityUnicodeCompare } from "../coordination/authority_store_codec.ts"; import { qualifySettlementHistory } from "../quota/settlement_phase.ts"; +import { normalizeStrideStrategy } from "./stride_policy.ts"; export const PROGRESS_RESULT_CLASSES = [ "advanced", "unchanged", "blocked", "exploration_exhausted", "no_followup", @@ -64,6 +65,7 @@ interface Request { neutral: ReadonlySet; stall: number; periodic: number; + stride: string; monitor: number; streak: number; monitorSchema: string; @@ -134,9 +136,13 @@ function decode(value: unknown): Request { retryObservedAt: optionalNonEmptyString(raw.retry_observed_at, "retry_observed_at"), agent: optionalNonEmptyString(raw.agent_id, "agent_id"), monitorAgent: optionalNonEmptyString(raw.monitor_agent_id, "monitor_agent_id"), - neutral: new Set(requireStringArray(raw.neutral_classifications, "neutral_classifications")), + // Older callers may omit canonical no-change writebacks. They are neutral + // for lane selection, work counting and feedback selection alike. + neutral: new Set([...requireStringArray(raw.neutral_classifications, "neutral_classifications"), + "replan_noop", "repair_noop"]), stall: integer(raw.stall_threshold, "stall_threshold", 2), periodic: integer(raw.periodic_threshold, "periodic_threshold", 1), + stride: normalizeStrideStrategy(raw.stride_strategy), monitor: integer(raw.monitor_threshold, "monitor_threshold", 1), streak: integer(raw.streak_threshold, "streak_threshold", 1), monitorSchema: requireNonEmptyString(raw.monitor_schema, "monitor_schema"), @@ -182,23 +188,57 @@ function decode(value: unknown): Request { } /** Scope before ACK: a peer's acknowledgement cannot discharge this lane. */ +function historyLane(request: Request): string | null { + return request.agent ?? (request.operation === "all" + ? request.runs.find(run => !request.neutral.has(run.classification) && run.agent)?.agent ?? null : null); +} function historyWindow(request: Request): Run[] { - // These canonical writeback classes carry no accepted replan work. Older - // callers may omit them from their neutral inventory; neither their repeated - // observations nor their timestamps can create a replacement review duty. - const neutral = new Set([...request.neutral, "replan_noop", "repair_noop"]); - const agent = request.agent ?? (request.operation === "all" - ? request.runs.find(run => !neutral.has(run.classification) && run.agent)?.agent : null); + const agent = historyLane(request); const result: Run[] = []; for (const run of request.runs) { if (agent && run.agent && run.agent !== agent) continue; if (run.ack) break; - if (neutral.has(run.classification)) continue; + if (request.neutral.has(run.classification)) continue; result.push(run); } return result; } +/** Feedback changes only the next periodic count, never work/stall/authority. + * Decisions come from the settled writeback, not projected history or Vision's + * carried-forward value. Include the last ACK itself; otherwise a decision made + * at that boundary could never affect its following interval. Never cross it. + */ +function periodicThreshold(request: Request, decisions: ReadonlyMap): number { + const lane = historyLane(request); + if (request.stride === "off" || !lane) return request.periodic; + for (const run of request.runs) { + if (run.agent && run.agent !== lane) continue; + // A later observation can reuse an older Turn id without being its + // writeback. Only the current ACK may select its settled feedback. + if (!run.ack) continue; + const decision = !request.neutral.has(run.classification) && run.agent === lane && run.turn + ? decisions.get(JSON.stringify([lane, run.turn])) : undefined; + if (decision) { + // Switching starts a new route at the configured baseline even if its + // basis names a gap. Uncertainty does not silently veto that action. + if (decision.action === "switch") return request.periodic; + if (decision.action === "verify" || + ["uncertain_evidence", "unexplained_gap", "decision_failure"].includes(String(decision.basis))) { + return Math.max(1, Math.ceil(request.periodic / 2)); + } + const review = jsonObject(decision.previous_review); + if (decision.action === "grow" && decision.basis === "supported_progress" && + review?.goal_gain === "improved" && Array.isArray(review.evidence_refs) && review.evidence_refs.length) { + return Math.min(Number.MAX_SAFE_INTEGER, Math.ceil(request.periodic * 1.5)); + } + return request.periodic; + } + return request.periodic; + } + return request.periodic; +} + /** De-duplicate accepted evidence, not arbitrary records sharing its turn. * A leading untyped row cannot erase an older typed observation from that turn. * Null legacy identities remain independent; neutral rows were already removed. @@ -349,8 +389,10 @@ function monitorStreak(request: Request): Trigger | null { }; } -export function projectReplanHistory(value: unknown, qualified?: ReadonlySet): JsonObject { - const request = decode(value); +export function projectReplanHistory(value: unknown, qualified?: ReadonlySet, + settledDecisions: ReadonlyMap = new Map()): JsonObject { + const decoded = decode(value); + const request = {...decoded, periodic: periodicThreshold(decoded, settledDecisions)}; const runs = historyWindow(request); let trigger: Trigger | null; switch (request.operation) { diff --git a/loopx/control_plane/work_items/replan_history_codec.py b/loopx/control_plane/work_items/replan_history_codec.py index 8985a4002d..d0dd516c8d 100644 --- a/loopx/control_plane/work_items/replan_history_codec.py +++ b/loopx/control_plane/work_items/replan_history_codec.py @@ -51,6 +51,7 @@ def effective_turn_cadence_context( "goal_ref": goal_ref, "source_admission": source_admission, "threshold": threshold, + "stride_strategy": profile.get("stride_strategy", "off"), "settlement_source": { "runtime_root": str(runtime_root.resolve()), "goal_id": goal["id"], @@ -196,6 +197,7 @@ def project_replan_history( if effective_turn_cadence is not None: params["settlement_source"] = effective_turn_cadence["settlement_source"] params["periodic_threshold"] = effective_turn_cadence["threshold"] + params["stride_strategy"] = effective_turn_cadence.get("stride_strategy", "off") try: if effective_turn_cadence is None: result = project_replan_request(params) diff --git a/loopx/control_plane/work_items/replan_history_settlement.ts b/loopx/control_plane/work_items/replan_history_settlement.ts index eb5d489ff4..cd8104787a 100644 --- a/loopx/control_plane/work_items/replan_history_settlement.ts +++ b/loopx/control_plane/work_items/replan_history_settlement.ts @@ -11,6 +11,7 @@ import { readGoalRolloutEventSnapshot, strictGoalRolloutEvents } from "../rollou import { parseQuotaAccountingOwner, withQuotaAccountingOwner, withBorrowedQuotaAccountingOwner, quotaOwnerOwnsProjection } from "../quota/source_admission.ts"; import { projectReplanHistory } from "./replan_history.ts"; +import { normalizeRecordedStrideDecision, STRIDE_DECISION_SCHEMA } from "./stride_policy.ts"; export async function projectSettledReplanHistory(value: unknown): Promise { const request = requireJsonObject(value, "replan history"); @@ -51,6 +52,7 @@ export async function projectSettledReplanHistory(value: unknown): Promise(); + const decisions = new Map(); const seen = new Set(); for (const raw of settlementRuns) { const row = jsonObject(raw); @@ -68,8 +70,24 @@ export async function projectSettledReplanHistory(value: unknown): Promise None: if provider == "sqlite": isolate_sqlite_runtime(tmp_path, monkeypatch) @@ -417,7 +419,7 @@ def call(*args: str, expected_code: int = 0) -> dict: "schema_version": "stride_decision_v2", "strategy_version": "evidence_conditioned_v1", "reference_context_id": control["reference_context_id"], "reference_obligation_id": obligation["obligation_id"], - "basis": "uncertain_evidence", "action": "verify", + "basis": "uncertain_evidence", "action": stride_action, "axes": { "work_slice": {"plan": "Complete the independent source validation.", "reason": "One complete check resolves the pending evidence question."}, @@ -432,6 +434,9 @@ def call(*args: str, expected_code: int = 0) -> dict: "cost_bound": "One source and its contrary case before replanning.", "minimum_evidence": "A checked result and a contrary-case result."}, } + if stride_action == "switch": + decision["axes"]["route_distance"]["plan"] = "Replace the original source with an independent source; retain the objective." + decision["axes"]["route_distance"]["reason"] = "The independent source changes the limiting evidence mechanism." vision_packet = { "schema_version": "goal_vision_replan_contract_v0", "state": "vision_patch_proposed", @@ -522,7 +527,7 @@ def call(*args: str, expected_code: int = 0) -> dict: # Continue through real authority/history, rather than injecting a crafted # context. The next periodic review must see the accepted autonomous decision. - for index in range(2): + for index in range(followthrough_count): turn = f"stride-followthrough-{index}" call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", successor["todo_id"], "--turn-instance-id", turn, @@ -540,7 +545,7 @@ def call(*args: str, expected_code: int = 0) -> dict: "--delivery-outcome", "outcome_progress", "--delivery-boundary", "in_flight_continuation", "--recommended-action", "The candidate regressed (error 5 versus reference 2); retain the reference and compare another source.", *(["--progress-result-class", "unchanged", "--progress-evidence-id", f"fixture:negative-result-{index}"] - if index == 0 else []), + if stride_action == "switch" and index == 0 else []), "--no-global-sync", "--suppress-external-sinks") call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", successor["todo_id"], "--turn-instance-id", turn, @@ -551,13 +556,16 @@ def call(*args: str, expected_code: int = 0) -> dict: "--agent-id", AGENT_ID, "--turn-instance-id", "stride-next-review", ) next_obligation = next_guard["autonomous_replan_obligation"] + periodic_trigger = next(row for row in next_obligation["triggers"] if row["kind"] == "periodic_review_due") + assert periodic_trigger["threshold"] == periodic_trigger["run_count"] == followthrough_count delivered_result = next_obligation["replan_context"]["decision_followthrough"]["ordinary_work"]["evidence"][0] observed_result = call("history", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--evidence-ref", delivered_result["evidence_ref"])["evidence"] assert "regressed (error 5 versus reference 2)" in observed_result["recommended_action"] # The actor can now discover the actual ordinary result from the review, # retain the reference, and choose a successor through the existing owner. - measurement = json.loads((project / "candidate-result-1.json").read_text()) + result_path = project / f"candidate-result-{followthrough_count - 1}.json" + measurement = json.loads(result_path.read_text()) next_task = "Compare another source while retaining the reference" if measurement["candidate_error"] > measurement["reference_error"] else "Adopt the candidate" next_successor = call("todo", "add", "--goal-id", GOAL_ID, "--role", "agent", "--claimed-by", AGENT_ID, "--text", next_task, @@ -581,7 +589,7 @@ def call(*args: str, expected_code: int = 0) -> dict: assert len(planned) == 1 and planned[0]["todo_id"] == successor["todo_id"] assert planned[0]["current"]["claimed_by"] == AGENT_ID assert planned[0]["current"]["status"] == "done", "A negative experiment can end while the Goal continues" - assert len(planned[0]["observation_refs"]) == 1 + assert len(planned[0]["observation_refs"]) == (1 if stride_action == "switch" else 0) assert follow["unresolved_planned_work_count"] == 1 last_work = json.loads(Path(work["json_path"]).read_text()) assert "progress_observation" not in last_work @@ -666,7 +674,7 @@ def call(*args: str, expected_code: int = 0) -> dict: "import json,sys; from pathlib import Path; " "old=json.loads(Path(sys.argv[1]).read_text()); " "Path(sys.argv[2]).write_text(json.dumps({'retained_error':old['reference_error'],'alternative_error':1}))", - str(project / "candidate-result-1.json"), str(project / f"adopted-probe-{index}.json")], check=True) + str(result_path), str(project / f"adopted-probe-{index}.json")], check=True) call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", next_successor["todo_id"], "--turn-instance-id", turn, "--classification", "validated_progress", "--delivery-batch-scale", "implementation", @@ -676,6 +684,28 @@ def call(*args: str, expected_code: int = 0) -> dict: call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", next_successor["todo_id"], "--turn-instance-id", turn, "--slots", "1", "--source", "heartbeat", "--execute") + if index == 0: + from loopx.history import load_index_snapshot + + runs = load_index_snapshot(runtime / "goals" / GOAL_ID / "runs/index.jsonl", + include_artifact_status=False).records + cadence = effective_turn_cadence_context(resolve_todo_replan_cadence_goal( + json.loads(registry.read_text())["goals"][0], runtime, + ), runtime, registry_path=registry) + # Real settled verify/switch A, then switch ACK B, then one work + # Turn: baseline 2 is not due. A late neutral observation reusing + # A's identity has neither ACK nor Vision and must not revive A. + # Mutate only the projection request, never provider state/receipts. + for classification in ("replan_noop", "repair_noop", "quota_slot_spent"): + alias = {**({"goal_ref": persisted["goal_ref"]} if "goal_ref" in persisted else {}), + "agent_id": AGENT_ID, + "turn_instance_id": "stride-due-review", + "generated_at": datetime.now(timezone.utc).isoformat(), + "classification": classification} + assert project_replan_history( + [alias, *reversed(runs)], operation="periodic", agent_id=AGENT_ID, + effective_turn_cadence=cadence, + ) is None, classification final = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--turn-instance-id", "review-adopted-probe", ) use = final["autonomous_replan_obligation"]["replan_context"]["decision_followthrough"]["work_result_use"] diff --git a/tests/control_plane/test_replan_history_policy.py b/tests/control_plane/test_replan_history_policy.py index a14671e89a..666127473a 100644 --- a/tests/control_plane/test_replan_history_policy.py +++ b/tests/control_plane/test_replan_history_policy.py @@ -191,6 +191,25 @@ def capture(method, params): assert requests[0][1]["todos"]["resume"] is None +def test_cadence_codec_transports_strategy_without_reclassifying_or_scaling(monkeypatch, tmp_path) -> None: + from loopx.control_plane.work_items import replan_history_codec as codec + + requests = [] + def project(params, context): + requests.append(params) + return {"schema_version": "replan_history_result_v0", "trigger": None} + monkeypatch.setattr(codec, "_project_admitted_replan_request", project) + goal = {"id": "fixture", "execution_profile": {"replan_after_effective_turns": 6, + "stride_strategy": "evidence_conditioned_v1"}} + context = codec.effective_turn_cadence_context(goal, tmp_path) + codec.project_replan_history(effective_turn_cadence=context) + assert requests[-1]["periodic_threshold"] == 6 + assert requests[-1]["stride_strategy"] == "evidence_conditioned_v1" + del goal["execution_profile"]["stride_strategy"] + codec.project_replan_history(effective_turn_cadence=codec.effective_turn_cadence_context(goal, tmp_path)) + assert requests[-1]["stride_strategy"] == "off" + + def test_long_history_keeps_evidence_beyond_rpc_limit() -> None: # A retry flood is not twenty turns. The second distinct observation is # deliberately older than the entire flood; tail/window truncation loses it. diff --git a/tests/control_plane_ts/replan_history.test.ts b/tests/control_plane_ts/replan_history.test.ts index 26aa7e0e85..83a8f6bfb7 100644 --- a/tests/control_plane_ts/replan_history.test.ts +++ b/tests/control_plane_ts/replan_history.test.ts @@ -35,6 +35,90 @@ function trigger(runs: JsonObject[], patch: JsonObject = {}): JsonObject | null } const many = (count: number, build = run): JsonObject[] => Array.from({ length: count }, (_, i) => build(count - i)); +test("settled stride feedback scales only the next periodic interval from its configured baseline", () => { + const ack = run(0, {accepted_ack: true}); + const key = JSON.stringify(["worker-a", "turn-0"]); + const project = (count: number, decision: JsonObject, patch: JsonObject = {}) => { + const rows = [...many(count), ack]; + return projectReplanHistory(request(rows, {operation: "periodic", periodic_threshold: 6, + stride_strategy: "evidence_conditioned_v1", ...patch}), + new Set(rows.map(row => JSON.stringify([row.agent_id, row.turn_id]))), + new Map([[key, decision]])).trigger as JsonObject | null; + }; + const grow = {action: "grow", basis: "supported_progress", previous_review: { + goal_gain: "improved", evidence_refs: ["evidence:subsequent-work"]}}; + assert.equal(project(8, grow), null); + assert.equal(project(9, grow)?.threshold, 9); + assert.equal(project(20, grow)?.threshold, 9, "feedback does not compound on every read"); + for (const decision of [{action: "verify", basis: "supported_progress"}, + ...["uncertain_evidence", "unexplained_gap", "decision_failure"].map(basis => ({action: "grow", basis}))]) { + assert.equal(project(2, decision), null); + assert.equal(project(3, decision)?.threshold, 3); + assert.equal(project(3, decision, {periodic_threshold: 5})?.threshold, 3, "round fractional counts up"); + assert.equal(project(1, decision, {periodic_threshold: 1})?.threshold, 1); + } + for (const decision of [{action: "switch", basis: "unexplained_gap"}, + {...grow, previous_review: {goal_gain: "flat", evidence_refs: ["evidence:negative"]}}, + {...grow, previous_review: {goal_gain: "improved", evidence_refs: []}}, + {...grow, previous_review: undefined}, {...grow, basis: "useful_new_route"}]) { + assert.equal(project(5, decision), null); + assert.equal(project(6, decision)?.threshold, 6); + } + assert.equal(project(6, grow, {stride_strategy: "off"})?.threshold, 6); + assert.equal(project(6, grow, {stride_strategy: undefined})?.threshold, 6); +}); + +test("cadence feedback cannot borrow a peer, unsettled decision or a decision before the current ACK", () => { + const verify = {action: "verify", basis: "uncertain_evidence"}; + const key = (agent: string, turn: string) => JSON.stringify([agent, turn]); + const old = run(0, {accepted_ack: true}); + const rows = [...many(3), old]; + const qualified = new Set(rows.map(row => key(String(row.agent_id), String(row.turn_id)))); + const project = (history: JsonObject[], decisions: Map) => + projectReplanHistory(request(history, {periodic_threshold: 6, stride_strategy: "evidence_conditioned_v1"}), + qualified, decisions).trigger as JsonObject | null; + assert.equal(project(rows, new Map()), null, "a projected decision is not settlement"); + assert.equal(project(rows, new Map([[key("peer", "turn-0"), verify]])), null); + assert.equal(project([...many(3), run(4, {accepted_ack: true}), old], + new Map([[key("worker-a", "turn-0"), verify]])), null, "do not cross a newer ACK without stride feedback"); + const decisions = new Map([[key("worker-a", "turn-0"), verify]]); + assert.equal(project([run(9, {agent_id: "peer", accepted_ack: true}), ...rows], decisions)?.threshold, 3); + const stalled = [run(2, {progress: observation}), run(1, {progress: observation}), old]; + const before = projectReplanHistory(request(stalled), qualified).trigger; + assert.deepEqual(project(stalled, decisions), before, "typed stalls retain their original priority and threshold"); +}); + +test("late observations cannot revive an older settled stride decision by reusing its Turn", () => { + const key = (turn: string) => JSON.stringify(["worker-a", turn]); + const old = run(0, {accepted_ack: true}); + const current = run(4, {accepted_ack: true}); + const decisions = new Map([ + [key("turn-0"), {action: "verify", basis: "uncertain_evidence"}], + [key("turn-4"), {action: "switch", basis: "useful_new_route"}], + ]); + const qualified = new Set([key("turn-1"), key("turn-2")]); + const project = (rows: JsonObject[], feedback = decisions, patch: JsonObject = {}) => + projectReplanHistory(request(rows, {operation: "periodic", periodic_threshold: 2, + stride_strategy: "evidence_conditioned_v1", ...patch}), qualified, feedback).trigger as JsonObject | null; + // These are request-local history observations, not synthetic settlements. + // Neutral inventory omitted by an older caller must retain the same rule. + for (const classification of ["replan_noop", "repair_noop", ...neutral, "status_readback"]) { + const alias = run(9, {turn_id: "turn-0", classification}); + assert.equal(project([alias, run(1), current, old]), null, classification); + assert.equal(project([alias, run(2), run(1), current, old])?.threshold, 2, classification); + assert.equal(project([alias, run(1), current, old], + new Map([[key("turn-0"), decisions.get(key("turn-0"))!]])), null, + "the newer ACK stops lookup even when it has no settled decision"); + assert.equal(project([alias, run(1), current, old], decisions, {stride_strategy: "off"}), null); + } + // A neutral ACK still cuts the existing window, but cannot supply feedback. + assert.equal(project([run(1), {...old, classification: "replan_noop"}], + new Map([[key("turn-0"), decisions.get(key("turn-0"))!]])), null); + assert.equal(project([run(1), old], + new Map([[key("turn-0"), decisions.get(key("turn-0"))!]]))?.threshold, 1, + "the genuine latest ACK remains inclusive"); +}); + test("effective cadence uses qualified lane identities, independent of Todo completion", () => { const qualified = new Set([1, 2].map(n => JSON.stringify(["worker-a", `turn-${n}`]))); const project = (runs: JsonObject[]) => projectReplanHistory(request(runs, {