outfitter-dispatch 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/PKG-INFO +1 -1
  2. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/plugins/dispatch/skills/dispatch/SKILL.md +3 -1
  3. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/pyproject.toml +1 -1
  4. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/client/client.py +5 -2
  5. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/client/transport.py +12 -1
  6. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/derive_cli.py +1 -1
  7. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/handlers.py +11 -1
  8. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/client/test_client.py +12 -1
  9. outfitter_dispatch-0.2.1/tests/client/test_transport.py +63 -0
  10. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/core/test_handlers.py +21 -0
  11. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/surfaces/test_derive_cli.py +20 -0
  12. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/uv.lock +1 -1
  13. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/.gitignore +0 -0
  14. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/AGENTS.md +0 -0
  15. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/README.md +0 -0
  16. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/justfile +0 -0
  17. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/plugins/dispatch/.codex-plugin/plugin.json +0 -0
  18. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/plugins/dispatch/.mcp.json +0 -0
  19. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/plugins/dispatch/README.md +0 -0
  20. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/plugins/dispatch/assets/dispatch.svg +0 -0
  21. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/plugins/dispatch/skills/README.md +0 -0
  22. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/plugins/dispatch/skills/dm/SKILL.md +0 -0
  23. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/__init__.py +0 -0
  24. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/cli.py +0 -0
  25. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/client/__init__.py +0 -0
  26. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/client/errors.py +0 -0
  27. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/client/events.py +0 -0
  28. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/client/models.py +0 -0
  29. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/client/router.py +0 -0
  30. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/config.py +0 -0
  31. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/__init__.py +0 -0
  32. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/context.py +0 -0
  33. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/derive_mcp.py +0 -0
  34. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/errors.py +0 -0
  35. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/examples.py +0 -0
  36. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/execute.py +0 -0
  37. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/op.py +0 -0
  38. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/contracts/registry.py +0 -0
  39. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/__init__.py +0 -0
  40. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/models.py +0 -0
  41. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/new_config.py +0 -0
  42. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/ops.py +0 -0
  43. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/queue.py +0 -0
  44. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/reactor.py +0 -0
  45. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/scheduler.py +0 -0
  46. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/trigger_handlers.py +0 -0
  47. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/core/triggers.py +0 -0
  48. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/daemon/__init__.py +0 -0
  49. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/daemon/__main__.py +0 -0
  50. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/daemon/control.py +0 -0
  51. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/daemon/host.py +0 -0
  52. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/daemon/lifecycle.py +0 -0
  53. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/daemon/supervisor.py +0 -0
  54. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/doctor.py +0 -0
  55. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/registry/__init__.py +0 -0
  56. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/registry/models.py +0 -0
  57. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/registry/store.py +0 -0
  58. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/surfaces/__init__.py +0 -0
  59. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/surfaces/cli.py +0 -0
  60. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/surfaces/mcp.py +0 -0
  61. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/src/outfitter/dispatch/version.py +0 -0
  62. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/__init__.py +0 -0
  63. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/client/__init__.py +0 -0
  64. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/client/conftest.py +0 -0
  65. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/client/test_events.py +0 -0
  66. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/client/test_models.py +0 -0
  67. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/client/test_router.py +0 -0
  68. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/conftest.py +0 -0
  69. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/contracts/__init__.py +0 -0
  70. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/contracts/test_contracts.py +0 -0
  71. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/core/__init__.py +0 -0
  72. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/core/test_examples.py +0 -0
  73. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/core/test_new_config.py +0 -0
  74. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/core/test_trigger_handlers.py +0 -0
  75. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/core/test_triggers.py +0 -0
  76. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/daemon/__init__.py +0 -0
  77. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/daemon/test_control.py +0 -0
  78. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/daemon/test_lifecycle.py +0 -0
  79. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/daemon/test_supervisor.py +0 -0
  80. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/fakes.py +0 -0
  81. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/integration/__init__.py +0 -0
  82. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/integration/_drive.py +0 -0
  83. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/integration/conftest.py +0 -0
  84. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/integration/test_app_server.py +0 -0
  85. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/integration/test_daemon_e2e.py +0 -0
  86. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/integration/test_lifecycle_e2e.py +0 -0
  87. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/registry/__init__.py +0 -0
  88. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/registry/test_store.py +0 -0
  89. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/registry/test_triggers_store.py +0 -0
  90. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/surfaces/__init__.py +0 -0
  91. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/surfaces/test_derive_mcp.py +0 -0
  92. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/surfaces/test_mcp_routing.py +0 -0
  93. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/surfaces/test_parity.py +0 -0
  94. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/test_doctor.py +0 -0
  95. {outfitter_dispatch-0.2.0 → outfitter_dispatch-0.2.1}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: outfitter-dispatch
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Local control plane for orchestrating Codex agent lanes over the Codex App Server.
5
5
  Project-URL: Homepage, https://github.com/outfitter-dev/dispatch
6
6
  Project-URL: Repository, https://github.com/outfitter-dev/dispatch
@@ -89,7 +89,9 @@ processes and there is no cross-process write interlock.
89
89
 
90
90
  Attach is bounded: if the app-server stalls, the underlying `thread/resume`
91
91
  times out (~15s) and `attach` fails with a clear `app_server` error, registering
92
- no lane. There is no half-attached state to clean up.
92
+ no lane. There is no half-attached state to clean up. Large persisted histories
93
+ are supported; attach should not fail only because a resumed thread has more than
94
+ 64 KiB of turns.
93
95
 
94
96
  ## Discover Sessions
95
97
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "outfitter-dispatch"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Local control plane for orchestrating Codex agent lanes over the Codex App Server."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -15,7 +15,7 @@ from typing import Self
15
15
 
16
16
  from pydantic import BaseModel
17
17
 
18
- from .errors import ProtocolError, TransportError
18
+ from .errors import ClientError, TransportError
19
19
  from .events import LaneEvent
20
20
  from .models import (
21
21
  ApprovalPolicy,
@@ -101,9 +101,12 @@ class AppServerClient:
101
101
  if message is None:
102
102
  break
103
103
  self._router.handle(message)
104
- except (TransportError, ProtocolError) as exc:
104
+ except ClientError as exc:
105
105
  self._router.fail_all(exc)
106
106
  return
107
+ except Exception as exc:
108
+ self._router.fail_all(TransportError(f"app-server read loop failed: {exc}"))
109
+ return
107
110
  self._router.fail_all(TransportError("app-server stream closed (stdout EOF)"))
108
111
  finally:
109
112
  self._closed_event.set() # wake supervisors waiting on wait_closed()
@@ -18,6 +18,8 @@ from typing import Protocol
18
18
 
19
19
  from .errors import ProtocolError, TransportError
20
20
 
21
+ DEFAULT_STDIO_READ_LIMIT = 64 * 1024 * 1024
22
+
21
23
 
22
24
  class Transport(Protocol):
23
25
  """Bidirectional JSON message channel to one app-server connection."""
@@ -38,9 +40,11 @@ class StdioTransport:
38
40
  self,
39
41
  argv: Sequence[str] = ("codex", "app-server", "--listen", "stdio://"),
40
42
  env: Mapping[str, str] | None = None,
43
+ read_limit: int = DEFAULT_STDIO_READ_LIMIT,
41
44
  ) -> None:
42
45
  self._argv = tuple(argv)
43
46
  self._env = dict(env) if env is not None else None
47
+ self._read_limit = read_limit
44
48
  self._proc: asyncio.subprocess.Process | None = None
45
49
  self._stderr_tail: deque[str] = deque(maxlen=50)
46
50
  self._stderr_task: asyncio.Task[None] | None = None
@@ -53,6 +57,7 @@ class StdioTransport:
53
57
  stdout=asyncio.subprocess.PIPE,
54
58
  stderr=asyncio.subprocess.PIPE,
55
59
  env=self._env,
60
+ limit=self._read_limit,
56
61
  )
57
62
  except OSError as exc: # binary missing / not executable
58
63
  raise TransportError(f"failed to spawn {self._argv[0]}: {exc}") from exc
@@ -82,7 +87,13 @@ class StdioTransport:
82
87
  proc = self._require_proc()
83
88
  assert proc.stdout is not None
84
89
  while True:
85
- raw = await proc.stdout.readline()
90
+ try:
91
+ raw = await proc.stdout.readline()
92
+ except ValueError as exc:
93
+ raise ProtocolError(
94
+ "app-server JSONL line exceeded dispatch's stdio reader limit "
95
+ f"({self._read_limit} bytes)"
96
+ ) from exc
86
97
  if raw == b"": # EOF: process closed stdout (crashed or exited)
87
98
  return None
88
99
  text = raw.decode(errors="replace").strip()
@@ -125,7 +125,7 @@ def _op_command(
125
125
  def command(**kwargs: object) -> None:
126
126
  json_requested = bool(kwargs.pop("json", False))
127
127
  if op.intent == "destroy":
128
- typer.confirm(f"Run destroy op {op.id!r}?", abort=True)
128
+ typer.confirm(f"Run destroy op {op.id!r}?", abort=True, err=json_requested)
129
129
  result = invoke(op.id, dict(kwargs))
130
130
  render(op, result)
131
131
  _ignore_json(json_requested)
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
 
12
+ from outfitter.dispatch.client.errors import AppServerError as ClientAppServerError
12
13
  from outfitter.dispatch.client.errors import ClientError
13
14
  from outfitter.dispatch.client.models import SandboxPolicy, ThreadGoal, ThreadInfo, ThreadSandbox
14
15
  from outfitter.dispatch.contracts.context import Ctx
@@ -596,12 +597,21 @@ async def discover(inp: DiscoverInput, ctx: Ctx) -> Discovery:
596
597
  async def archive(inp: LaneInput, ctx: Ctx) -> LaneRef:
597
598
  lane = await _resolve(ctx, inp.lane)
598
599
  _require_writable(lane) # archiving mutates the shared thread store (ADR-0005)
599
- await ctx.client.thread_archive(lane.id)
600
+ try:
601
+ await ctx.client.thread_archive(lane.id)
602
+ except ClientAppServerError as exc:
603
+ if not _is_no_rollout_archive_error(exc):
604
+ raise
605
+ ctx.log.info("lane.archive_local_no_rollout", lane=lane.id)
600
606
  await ctx.registry.update_lane_status(lane.id, "archived")
601
607
  await ctx.registry.log_action("archive", lane=lane.id)
602
608
  return _ref(await ctx.registry.get_lane(lane.id))
603
609
 
604
610
 
611
+ def _is_no_rollout_archive_error(exc: ClientAppServerError) -> bool:
612
+ return exc.code == -32600 and "no rollout found" in exc.message.lower()
613
+
614
+
605
615
  async def status(inp: StatusInput, ctx: Ctx) -> StatusOutput:
606
616
  lanes = await ctx.registry.list_lanes()
607
617
  triggers = await ctx.registry.list_triggers()
@@ -7,7 +7,7 @@ import asyncio
7
7
  import pytest
8
8
 
9
9
  from outfitter.dispatch.client.client import AppServerClient
10
- from outfitter.dispatch.client.errors import AppServerError, TransportError
10
+ from outfitter.dispatch.client.errors import AppServerError, ProtocolError, TransportError
11
11
  from outfitter.dispatch.client.events import TurnCompleted
12
12
 
13
13
  from .conftest import FakeTransport, Responder
@@ -189,6 +189,17 @@ async def test_eof_fails_pending_request_with_transport_error(
189
189
  await task
190
190
 
191
191
 
192
+ async def test_reader_error_fails_pending_request() -> None:
193
+ class BrokenTransport(FakeTransport):
194
+ async def receive(self) -> dict[str, object] | None:
195
+ raise ProtocolError("line too large")
196
+
197
+ c = AppServerClient(BrokenTransport())
198
+ await c.start()
199
+ with pytest.raises(ProtocolError, match="line too large"):
200
+ await c.thread_read("L1")
201
+
202
+
192
203
  async def test_events_stream_yields_projected_lane_event(
193
204
  client: tuple[AppServerClient, FakeTransport],
194
205
  ) -> None:
@@ -0,0 +1,63 @@
1
+ """Unit tests for the stdio App Server transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from typing import Any
10
+
11
+ import pytest
12
+
13
+ from outfitter.dispatch.client.transport import StdioTransport
14
+
15
+
16
+ async def test_stdio_transport_sets_reader_limit(monkeypatch: pytest.MonkeyPatch) -> None:
17
+ captured: dict[str, Any] = {}
18
+
19
+ class DummyProcess:
20
+ returncode = None
21
+ stdin = None
22
+ stdout = None
23
+ stderr = None
24
+
25
+ async def fake_create_subprocess_exec(*argv: str, **kwargs: Any) -> DummyProcess:
26
+ captured["argv"] = argv
27
+ captured["kwargs"] = kwargs
28
+ return DummyProcess()
29
+
30
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
31
+
32
+ transport = StdioTransport(argv=("codex", "app-server"), read_limit=123_456)
33
+ await transport.start()
34
+
35
+ assert captured["argv"] == ("codex", "app-server")
36
+ assert captured["kwargs"]["limit"] == 123_456
37
+ assert captured["kwargs"]["stdin"] is asyncio.subprocess.PIPE
38
+ assert captured["kwargs"]["stdout"] is asyncio.subprocess.PIPE
39
+ assert captured["kwargs"]["stderr"] is asyncio.subprocess.PIPE
40
+
41
+
42
+ def test_stdio_transport_accepts_sequence_argv() -> None:
43
+ argv: Sequence[str] = ("codex", "app-server", "--listen", "stdio://")
44
+ transport = StdioTransport(argv=argv)
45
+ assert transport.returncode is None
46
+
47
+
48
+ async def test_stdio_transport_reads_large_jsonl_line() -> None:
49
+ payload = {"id": 1, "result": {"text": "x" * 70_000}}
50
+ script = (
51
+ "import json, sys; "
52
+ f"sys.stdout.write({json.dumps(json.dumps(payload))} + '\\n'); "
53
+ "sys.stdout.flush()"
54
+ )
55
+ transport = StdioTransport(
56
+ argv=(sys.executable, "-c", script),
57
+ read_limit=128 * 1024,
58
+ )
59
+ await transport.start()
60
+ try:
61
+ assert await transport.receive() == payload
62
+ finally:
63
+ await transport.close()
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  import pytest
10
10
  import pytest_asyncio
11
11
 
12
+ from outfitter.dispatch.client.errors import AppServerError as ClientAppServerError
12
13
  from outfitter.dispatch.client.errors import TransportError
13
14
  from outfitter.dispatch.client.models import ThreadGoal, ThreadInfo, ThreadStatus
14
15
  from outfitter.dispatch.contracts.errors import AppServerError, AuthorityError, ValidationError
@@ -543,6 +544,26 @@ async def test_roster_then_archive_flips_status(store: Registry) -> None:
543
544
  assert len(everything.lanes) == 1
544
545
 
545
546
 
547
+ class _NoRolloutArchiveClient(FakeLaneClient):
548
+ async def thread_archive(self, thread_id: str) -> None:
549
+ self._record("thread_archive", thread_id=thread_id)
550
+ raise ClientAppServerError(-32600, f"no rollout found for thread id {thread_id}")
551
+
552
+
553
+ async def test_archive_no_rollout_lane_marks_local_lane_archived(store: Registry) -> None:
554
+ client = _NoRolloutArchiveClient()
555
+ ctx = make_ctx(store, client)
556
+ await handlers.new_lane(NewInput(name="smoke", ephemeral=True, send=False), ctx)
557
+
558
+ archived = await handlers.archive(LaneInput(lane="lane-1"), ctx)
559
+
560
+ assert archived.status == "archived"
561
+ assert (await handlers.roster(RosterInput(), ctx)).lanes == []
562
+ everything = await handlers.roster(RosterInput(include_archived=True), ctx)
563
+ assert [lane.id for lane in everything.lanes] == ["lane-1"]
564
+ assert any(name == "thread_archive" for name, _ in client.calls)
565
+
566
+
546
567
  async def test_status_and_log_reflect_activity(store: Registry) -> None:
547
568
  ctx = make_ctx(store)
548
569
  await handlers.open_lane(OpenInput(name="one"), ctx)
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+
5
7
  import typer
6
8
  from typer.testing import CliRunner
7
9
 
@@ -149,6 +151,24 @@ def test_lane_archive_prompts_for_confirmation() -> None:
149
151
  assert confirmed.exit_code == 0
150
152
 
151
153
 
154
+ def test_json_destroy_prompt_does_not_pollute_stdout() -> None:
155
+ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]:
156
+ return {"id": "L1", "handle": "@x", "source": "own", "status": "archived"}
157
+
158
+ app = derive_cli(REGISTRY, invoke)
159
+ result = runner.invoke(app, ["lane", "archive", "L1", "--json"], input="y\n")
160
+
161
+ assert result.exit_code == 0
162
+ assert "Run destroy op" not in result.stdout
163
+ payload = result.stdout[result.stdout.index("{") :]
164
+ assert json.loads(payload) == {
165
+ "id": "L1",
166
+ "handle": "@x",
167
+ "source": "own",
168
+ "status": "archived",
169
+ }
170
+
171
+
152
172
  def test_schema_command_prints_derived_schema_without_daemon() -> None:
153
173
  app = derive_cli(REGISTRY, lambda _op, _params: {})
154
174
 
@@ -466,7 +466,7 @@ wheels = [
466
466
 
467
467
  [[package]]
468
468
  name = "outfitter-dispatch"
469
- version = "0.2.0"
469
+ version = "0.2.1"
470
470
  source = { editable = "." }
471
471
  dependencies = [
472
472
  { name = "aiosqlite" },