Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ A running server reports its version at `/health`.

## [Unreleased]

### Added

- Durable trip progress (INT-416): each conversation's turn (identity, action, status,
timestamps, deadline, active seconds, cancel request/outcome) and approval history are
persisted in the existing store, and the new `trip_progress` MCP tool reports every leg
revision's stage, the pending or cancelled approval, completed legs and uncertain
generations without invoking the model or a provider. After a restart, unfinished work is
reported as `interrupted` instead of `running`.
- Cooperative cancellation and turn deadlines (INT-417): the new `cancel_trip` MCP tool stops
queued or running work at the next safe point (model/tool boundary, browser queue wait or
immediately before Generate), withdraws a pending approval and approved revisions that were
never clicked, and keeps completed plans and briefings. A Generate click already made is
never interrupted or repeated; it is reported as generated or uncertain.
`IFD_TURN_DEADLINE_SECONDS` (default 600) bounds each turn's active execution; waiting for
a human approval happens between turns and is not counted.
- Separate `ifd-workflow-v050-trip-control` evaluation suite (WF-029–033) over the production
MCP tools with controlled providers.

### Changed

- `turn_status` falls back to the persisted turn record when no in-process turn exists, and
`conversation_status` reports `approval_cancelled` for a withdrawn approval;
`approve_dispatch` refuses to approve a cancelled approval. Existing statuses are unchanged.
- Workflow verifier `ifd-workflow-oracles-v4` adds MCP-output assertions and the cancelled
outcome; v1–v3 manifests remain accepted.

## [0.4.7] - 2026-11-12

### Fixed
Expand Down
18 changes: 18 additions & 0 deletions docs/implementation/agent-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ The review queue scores whole conversations. Automatic workflow feedback is sepa
owner acceptance. Provider usage reports FlightAware list-price estimates and Browserbase
elapsed time; unknown invoice costs stay unknown.

## v0.5.0 trip control (unreleased)

Remote MCP tools added for INT-416/417. Neither invokes the model or a provider.

| Tool | Use |
| --- | --- |
| `trip_progress(thread_id)` | Durable progress: the current/last turn (`queued`, `running`, `approval_required`, `reply`, `cancelled`, `deadline_exceeded`, `failed`, or `interrupted` after a restart), pending/cancelled approval and history, and each leg revision's stage (`awaiting_generation`, `generating`, `generated_awaiting_finalize`, `generation_uncertain`, `generation_failed`, `cancelled`, `plan_retained`, `briefing_ready`, `infeasible`), with `completed_legs`, `uncertain_generations` and a `next` instruction |
| `cancel_trip(thread_id, reason?)` | Only when the user asks to stop. Records the request, signals a running turn and waits up to 25 seconds for it to reach a safe point. Returns `cancelled` (with `outcome` `stopped` or `completed` when the turn finished first) or `cancelling` while a Generate click is finishing |

Cancellation withdraws the pending approval (`approve_dispatch` then returns `cancelled`) and
every approved revision whose Generate was never clicked; continuing needs a new approved
revision. Completed plans and briefings are kept. A clicked or uncertain generation is never
withdrawn, cleared or repeated: check SimBrief, then finalize or request a new revision.
`IFD_TURN_DEADLINE_SECONDS` (default 600) stops a turn at its next safe point and, like a
cancel, withdraws unclicked approved revisions; worker threads
are not killed, so a stalled model or provider call ends when it returns. Browser limits are
unchanged: one active workflow, two waiters, 30-second queue wait, 180-second session.

## Bounded browser management

Deployed in [v0.4.2](deployment.md#current-release-v042) after exact-image staging acceptance
Expand Down
31 changes: 31 additions & 0 deletions docs/technical-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,37 @@ Use separate component statuses for research/parking, plan generation, thrust, a

Before retrying generation after a timeout, reconcile using any supported external identity/receipt; never blindly generate again. If outcome cannot be reconciled, report it as unknown and request the necessary action. Retry limits, timeouts, and research bounds should be explicit operational settings selected from measured behavior.

### Implemented trip progress and cancellation (v0.5.0, INT-416/417)

State lives in the existing store without a schema change. Each conversation thread has one
`turn_progress:<thread_id>` JSON record in `settings` (SQLite and Postgres): `turn_id`,
`action`, `status`, `boot_id`, `started_at`/`finished_at`/`deadline_at`, `active_seconds` and
`active_seconds_total`, `cancel` (`requested_at`, `reason`, `while`, `outcome`,
`confirmed_at`), `cancelled_interrupt` and the approval decision history. Leg stages are
derived from the saved request, `leg_meta` and snapshot of each revision; a cancelled
revision carries `cancelled` in its metadata, which also fences `claim_generation`. The pending
approval comes from the LangGraph checkpoint.

Turn states: `queued → running → approval_required | reply | cancelled | deadline_exceeded |
failed`. A record left `queued`/`running` by another server process (`boot_id` differs) or
with no in-process turn is reported as `interrupted`. Cancel requests move
`requested → stopped` (the turn reached a safe point) or `requested → completed` (it
finished first); while a Generate click is in flight `cancel_trip` reports `cancelling`.

Leg stages: `awaiting_generation → generating → generated_awaiting_finalize → plan_retained →
briefing_ready | infeasible`, with `generation_failed`, `cancelled` (approval withdrawn before
any click) and `generation_uncertain` (clicked without an observed result, or a `clicking`
claim older than one bounded browser workflow or left by an inactive turn).

Cancellation is cooperative: checks run before each model call and tool call, while waiting for
browser admission and immediately before the Generate claim. Python threads are never killed
and a click is never interrupted, cleared or retried. A cancel that lands while the model is
proposing a dispatch withdraws that new approval instead of offering it. The turn deadline
(`IFD_TURN_DEADLINE_SECONDS`, default 600 seconds) covers active execution only; the human
approval wait falls between turns. The default exceeds one 180-second browser session plus the
30-second queue and model steps; recorded live sessions took 50–80 seconds. Per-turn
`active_seconds` is now recorded so the limit can be recalibrated from observed turns.

## MCP and presentation contract

Proposed task-level interface:
Expand Down
22 changes: 22 additions & 0 deletions evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,28 @@ A product exception is a valid observed failure; an invalid run means the harnes
cannot support a comparison. Manifest/fixture drift aborts before execution. Reports should
remain under ignored `artifacts/evals/` because they retain complete observations.

### v0.5.0 trip-control suite (INT-416/417)

`workflow_cases_v050.json` is the separate `ifd-workflow-v050-trip-control` manifest
(verifier `ifd-workflow-oracles-v4`, base commit v0.4.6). Its cases require `--mcp`; without
it a run is marked invalid. Cancellation comes from a concurrent MCP `cancel_trip` call and
the deadline from real elapsed time against `IFD_TURN_DEADLINE_SECONDS`; no clock patching or
live session is involved. `mcp_assertions` check `cancel_trip`, `approve_dispatch` and
`trip_progress` outputs.

| Case | Scenario |
| --- | --- |
| WF-029 | Cancel at the first approval; the approval cannot then be approved |
| WF-030 | Cancel at leg 2's approval after leg 1's briefing; leg 1 kept across a restart |
| WF-031 | Cancel before the Generate click; no click, revision withdrawn |
| WF-032 | Cancel after a click whose outcome times out; reported uncertain, one attempt only |
| WF-033 | Turn deadline passes before the click; no click, revision withdrawn |

```sh
uv run python evals/run_workflow_replay.py run \
--manifest evals/workflow_cases_v050.json --mcp --output artifacts/evals/v050-trip-control.json
```

### Optional LangSmith experiment upload

The installed `langsmith==0.14.16` supports [`Client.evaluate`](https://docs.langchain.com/langsmith/evaluate-llm-application).
Expand Down
5 changes: 5 additions & 0 deletions evals/mcp_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ def invoke(self, payload, config, **kwargs):
}
)
]
elif result["status"] == "cancelled":
# A cancelled or expired turn ends without a model reply; the harness inspects
# durable progress next instead of treating it as a transport failure.
self.run["cancelled"] = True
self.run.setdefault("mcp_outputs", []).append({"tool": name, "output": result})
elif result["status"] != "reply":
raise RuntimeError(f"unexpected MCP outcome: {result['status']}")
return SimpleNamespace(value=state, interrupts=interrupts)
Expand Down
63 changes: 63 additions & 0 deletions evals/run_workflow_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import os
import tempfile
import threading
import time
import sqlite3
from collections import Counter
Expand All @@ -32,6 +33,7 @@
flights,
observability,
operations,
progress,
remote,
workflow,
)
Expand Down Expand Up @@ -244,6 +246,30 @@ def now(_cls, tz=None):
continuation_turns = 0
turns = []
source = (ROOT / manifest["fixture"]["path"]).read_bytes()
control = case.get("control") or {}
concurrent = [] # cancel_trip calls made by a concurrent client while a turn runs
driver = None

def mcp_call(name, args):
output = driver.call(name, args)
run.setdefault("mcp_outputs", []).append({"tool": name, "output": output})
return output

def cancel_during_turn():
"""Cancel from a second client mid-turn, then continue once the turn sees it."""
if control.get("cancel_at") not in {"pre_click", "post_click"}:
return
caller = threading.Thread(
target=mcp_call,
args=("cancel_trip", {"thread_id": case["id"], "reason": "owner cancelled"}),
)
caller.start()
concurrent.append(caller)
deadline = time.monotonic() + 10
while not (case["id"] in remote._controls and remote._controls[case["id"]].event.is_set()):
if time.monotonic() > deadline:
raise RuntimeError("controlled cancellation was not received")
time.sleep(0.01)

def blocked(*args, **kwargs):
run["invalid"] = True
Expand Down Expand Up @@ -290,6 +316,11 @@ def generate(fields, click, shots_dir, before_click=None):
)
if failure == "timeout_before_click":
raise TimeoutError("controlled pre-click timeout")
if control.get("cancel_at") == "pre_click":
cancel_during_turn()
if control.get("cancel_at") == "deadline_pre_click":
# Real elapsed time past the configured turn deadline; no clock patching.
time.sleep(float(control["deadline_seconds"]) + 0.5)
if case["scenario"] == "retry_duplicate" and len(run["provider_attempts"]) == 1:
return {
"status": "fill_mismatch",
Expand All @@ -313,6 +344,8 @@ def generate(fields, click, shots_dir, before_click=None):
raw["navlog"] = {"fix": [{"ident": s} for s in case["route_evidence"]]}
body = json.dumps(raw).encode()
run["provider_plans"].append({"key": ident, "sha256": sha256(body), "fields": fields})
if control.get("cancel_at") == "post_click":
cancel_during_turn()
if failure == "timeout_after_click":
raise TimeoutError("controlled post-click timeout")
return {
Expand Down Expand Up @@ -383,6 +416,11 @@ def harvest(current):
"IFD_AIRPORT_CACHE_DIR": "disabled",
"IFD_INTERNAL_TOKEN": "controlled-workflow-replay-token",
"LANGSMITH_TRACING": "false",
**(
{"IFD_TURN_DEADLINE_SECONDS": str(control["deadline_seconds"])}
if "deadline_seconds" in control
else {}
),
},
)
)
Expand Down Expand Up @@ -410,12 +448,18 @@ def harvest(current):
(flights, "datetime", Clock),
(agent, "datetime", Clock),
(operations, "datetime", Clock),
(progress, "datetime", Clock),
(remote, "paid_operations_enabled", lambda: True),
):
stack.enter_context(patch.object(module, name, replacement))
# Do not export .env tracing settings or send ambient traces; explicit upload is separate.
stack.enter_context(tracing_context(enabled=False))
try:
if control and not mcp_url:
run["invalid"] = True
run["invalid_reasons"].append("trip-control case requires --mcp transport")
run.update(verify(case, run))
return run
if case.get("airline_flight"):
flight = case["airline_flight"]
required = {
Expand Down Expand Up @@ -528,6 +572,20 @@ def flight_get(path, params=None):
messages = result.value["messages"]
harvest(messages)
observe()
if (
result.interrupts
and control.get("cancel_at") == "approval"
and len(run["approvals"]) == control.get("approval_index", 0)
):
# The owner stops at this approval instead of answering it.
pending = result.interrupts[0].value["action_requests"][0]
run["cancelled_approvals"] = [pending]
cancelled = mcp_call(
"cancel_trip", {"thread_id": case["id"], "reason": "owner cancelled"}
)
run["cancelled"] = cancelled["status"] == "cancelled"
mcp_call("approve_dispatch", {"thread_id": case["id"], "approve": True})
break
if result.interrupts:
decisions = []
for interrupt in result.interrupts:
Expand Down Expand Up @@ -600,6 +658,11 @@ def flight_get(path, params=None):
graph = agent.build_agent(model=selected_model, checkpointer=replacement)
driver.graph = graph
run["recovered"] = driver.call("conversation_status", {"thread_id": case["id"]})
if control:
for caller in concurrent:
caller.join(timeout=remote.WAIT_SECONDS + 15)
mcp_call("turn_status", {"thread_id": case["id"]})
mcp_call("trip_progress", {"thread_id": case["id"]})
observe()
except Exception as exc:

Expand Down
Loading
Loading