harness-sdk-python 0.4.4__tar.gz → 0.5.0__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 (27) hide show
  1. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/pyproject.toml +1 -1
  3. harness_sdk_python-0.5.0/src/harness_sdk/__init__.py +4 -0
  4. harness_sdk_python-0.5.0/src/harness_sdk/linear_thread.py +31 -0
  5. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/src/harness_sdk/run_manager.py +146 -96
  6. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/run_helpers.py +38 -6
  7. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_batches.py +3 -3
  8. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_branch_anchor.py +2 -2
  9. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_edit_dispatched.py +5 -5
  10. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_edit_reload.py +8 -8
  11. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_enqueue.py +8 -8
  12. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_facade.py +2 -2
  13. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_input_required.py +126 -32
  14. harness_sdk_python-0.5.0/tests/test_linear_thread.py +87 -0
  15. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_meta.py +18 -18
  16. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_outcomes.py +7 -13
  17. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_rewind_during_run.py +7 -7
  18. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_run_leaf.py +16 -16
  19. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_settle.py +9 -9
  20. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_steer.py +6 -6
  21. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_stop_continue.py +82 -13
  22. harness_sdk_python-0.4.4/src/harness_sdk/__init__.py +0 -3
  23. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/.gitignore +0 -0
  24. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/README.md +0 -0
  25. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/src/harness_sdk/fenced_postgres.py +0 -0
  26. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_fenced_postgres.py +0 -0
  27. {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_placement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.4.4
3
+ Version: 0.5.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.4.4"
3
+ version = "0.5.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,4 @@
1
+ from .linear_thread import linear_thread_meta
2
+ from .run_manager import RunManager
3
+
4
+ __all__ = ["RunManager", "linear_thread_meta"]
@@ -0,0 +1,31 @@
1
+ from typing import Any, Callable
2
+
3
+ from .run_manager import GetMessageMeta
4
+
5
+
6
+ def linear_thread_meta(
7
+ *,
8
+ messages: Callable[[], list[dict[str, Any]]],
9
+ role: Callable[[dict[str, Any]], str],
10
+ ) -> GetMessageMeta:
11
+ """RunManager ``get_message_meta`` for a linear message list: ``messages`` returns the current list, ``role`` maps a message to its role."""
12
+ if not callable(messages):
13
+ raise TypeError("messages must be callable")
14
+ if not callable(role):
15
+ raise TypeError("role must be callable")
16
+
17
+ async def get_message_meta(message_id: str | None) -> dict[str, Any] | None:
18
+ items = messages()
19
+ if message_id is None:
20
+ return {"isLeaf": not items}
21
+ for index, message in enumerate(items):
22
+ if message.get("id") == message_id:
23
+ return {
24
+ "parentId": items[index - 1].get("id") if index > 0 else None,
25
+ "role": role(message),
26
+ "isLeaf": index == len(items) - 1,
27
+ "onActiveBranch": True,
28
+ }
29
+ return None
30
+
31
+ return get_message_meta
@@ -1,7 +1,7 @@
1
1
  """RunManager: the harness-sdk runs subsystem over a Statewire host.
2
2
 
3
3
  Implements the full state x command matrix from runs.mdx on a single
4
- ``start(ctx)`` executor entrypoint, per the RunManager design (d4987).
4
+ ``run(ctx)`` executor entrypoint, per the RunManager design (d4987).
5
5
 
6
6
  Commands stage entries into an intake list and schedule a drain; the drain
7
7
  reduces the whole intake into the lanes in order (pure placement), then takes
@@ -17,6 +17,7 @@ initiators settle rejected (``stopped`` or the failure).
17
17
  """
18
18
 
19
19
  import asyncio
20
+ import uuid
20
21
  from dataclasses import dataclass, field
21
22
  from typing import Any, Awaitable, Callable, Iterable
22
23
 
@@ -39,7 +40,7 @@ _CAPABILITIES = frozenset(
39
40
  }
40
41
  )
41
42
 
42
- _ENTRY_TYPES = (
43
+ _TRIGGERS = (
43
44
  "message-send",
44
45
  "message-edit",
45
46
  "message-reload",
@@ -146,7 +147,7 @@ class RunManager:
146
147
  self,
147
148
  *,
148
149
  state: Any,
149
- start: Callable[["RunManager.StartContext"], Awaitable[Any]],
150
+ run: Callable[["RunManager.RunContext"], Awaitable[Any]],
150
151
  get_message_meta: GetMessageMeta,
151
152
  create_task: Callable[[Any], "asyncio.Task[Any]"],
152
153
  schedule: Callable[[Callable[[], None]], None],
@@ -162,14 +163,14 @@ class RunManager:
162
163
  if max_queued < 1:
163
164
  raise ValueError("max_queued must be >= 1")
164
165
  self._state = state
165
- self._start = start
166
+ self._run = run
166
167
  self._get_message_meta = get_message_meta
167
168
  self._capabilities = caps
168
169
  self._create_task = create_task
169
170
  self._schedule = schedule
170
171
  self._max_queued = max_queued
171
172
  self._task: "asyncio.Task[Any] | None" = None
172
- self._ctx: "RunManager.StartContext | None" = None
173
+ self._ctx: "RunManager.RunContext | None" = None
173
174
  self._dispatched_ids: tuple[str, ...] = ()
174
175
  self._dispatch_record: dict[str, Any] | None = None
175
176
  self._intake: list[Any] = []
@@ -196,20 +197,52 @@ class RunManager:
196
197
  def _init_state(self) -> None:
197
198
  # Runs state is write-only and not durable: overwrite whatever is there.
198
199
  self._state["status"] = "ready"
199
- self._state["error"] = None
200
- self._state["queue"] = []
201
- self._state["steerQueue"] = []
202
- self._state["runLeafMessageId"] = None
203
- self._state.pop("dispatch", None)
204
- self._state.pop("inputRequests", None)
200
+ self._state["runs"] = []
201
+ for key in (
202
+ "error",
203
+ "queue",
204
+ "steerQueue",
205
+ "runId",
206
+ "runLeafMessageId",
207
+ "dispatch",
208
+ "inputRequests",
209
+ ):
210
+ self._state.pop(key, None)
205
211
 
206
212
  # ─── State access ───────────────────────────────────────
207
213
 
208
214
  def _status(self) -> str:
209
215
  return self._state["status"]
210
216
 
217
+ def _entry(self) -> Any:
218
+ runs = self._state["runs"]
219
+ return runs[0] if len(runs) else None
220
+
221
+ def _ensure_entry(self) -> Any:
222
+ if len(self._state["runs"]) == 0:
223
+ self._state["runs"].append(
224
+ {
225
+ "runId": None,
226
+ "status": "ready",
227
+ "queue": [],
228
+ "steerQueue": [],
229
+ "error": None,
230
+ "runLeafMessageId": None,
231
+ }
232
+ )
233
+ return self._state["runs"][0]
234
+
235
+ def _set_status(self, status: str) -> None:
236
+ self._entry()["status"] = status
237
+ self._state["status"] = status
238
+
239
+ def _to_ready(self) -> None:
240
+ self._state["runs"] = []
241
+ self._state["status"] = "ready"
242
+
211
243
  def _lane_items(self, lane: str) -> list[dict[str, Any]]:
212
- return list(plain(self._state[lane]))
244
+ entry = self._entry()
245
+ return list(plain(entry[lane])) if entry is not None else []
213
246
 
214
247
  def _lane_of(self, message_id: str) -> str | None:
215
248
  for lane in ("steerQueue", "queue"):
@@ -326,96 +359,107 @@ class RunManager:
326
359
  fx.continues.clear()
327
360
  elif fx.new_added and pre_empty:
328
361
  self._pop_dispatchable()
329
- elif (
330
- status == "input-required"
331
- and self._input_requests
332
- and len(self._input_answers) == len(self._input_requests)
333
- ):
334
- responses = tuple(
335
- (request, *self._input_answers[request["id"]])
336
- for request in self._input_requests
337
- )
338
- self._clear_input()
339
- self._dispatch("input-resume", [], responses=responses)
362
+ elif status == "input-required":
363
+ if fx.steer_added and self._lane_items("steerQueue"):
364
+ self._pop_dispatchable(input_outcomes=self._take_input_outcomes())
365
+ elif self._input_requests and len(self._input_answers) == len(
366
+ self._input_requests
367
+ ):
368
+ self._dispatch(
369
+ "input-resume", [], input_outcomes=self._take_input_outcomes()
370
+ )
340
371
 
341
372
  def _settle_outcome(self, outcome: Any) -> None:
342
373
  if isinstance(outcome, RunManager.Complete):
343
374
  if not self._pop_dispatchable():
344
- self._state["status"] = "ready"
345
- self._state["runLeafMessageId"] = None
375
+ self._to_ready()
346
376
  return
347
377
  if isinstance(outcome, RunManager.InputRequired):
348
- self._state["status"] = "input-required"
349
378
  self._input_requests = [dict(r) for r in outcome.requests]
350
379
  self._input_answers = {}
351
- self._state["inputRequests"] = [
380
+ entry = self._entry()
381
+ entry["inputRequests"] = [
352
382
  {**r, "response": None} for r in outcome.requests
353
383
  ]
384
+ self._set_status("input-required")
354
385
  return
355
386
  status = "error" if isinstance(outcome, RunManager.Error) else "stopped"
356
387
  if outcome.dispatch_queue and self._pop_dispatchable():
357
388
  return
358
- self._state["status"] = status
389
+ self._set_status(status)
390
+ self._entry()["runId"] = None
359
391
 
360
392
  # ─── Dispatch and settle ────────────────────────────────
361
393
 
362
394
  def _clear_input(self) -> None:
363
395
  self._input_requests = []
364
396
  self._input_answers = {}
365
- self._state.pop("inputRequests", None)
397
+ entry = self._entry()
398
+ if entry is not None:
399
+ entry.pop("inputRequests", None)
400
+
401
+ def _take_input_outcomes(self) -> tuple[tuple[dict[str, Any], Any, Any], ...]:
402
+ outcomes = tuple(
403
+ (request, *self._input_answers.get(request["id"], (None, None)))
404
+ for request in self._input_requests
405
+ )
406
+ self._clear_input()
407
+ return outcomes
366
408
 
367
409
  def _dispatch(
368
410
  self,
369
- type: str,
411
+ trigger: str,
370
412
  messages: list[dict[str, Any]],
371
413
  *,
372
414
  rollback_to: Any = _ABSENT,
373
- responses: Any = _ABSENT,
374
415
  root_meta: Any = None,
416
+ input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = (),
375
417
  ) -> None:
376
- if type not in _ENTRY_TYPES:
377
- raise ValueError(f"invalid entry type: {type!r}")
418
+ if trigger not in _TRIGGERS:
419
+ raise ValueError(f"invalid trigger: {trigger!r}")
378
420
  if self._task is not None:
379
421
  raise RuntimeError("a run is already in flight")
380
422
  message_meta = {m["id"]: m.get("meta") for m in messages}
381
423
  messages = [
382
424
  {k: v for k, v in message.items() if k != "meta"} for message in messages
383
425
  ]
384
- record: dict[str, Any] = {"type": type, "messages": list(messages)}
426
+ record: dict[str, Any] = {"trigger": trigger, "messages": list(messages)}
385
427
  if rollback_to is not _ABSENT:
386
428
  record["rollbackTo"] = rollback_to
387
- if responses is not _ABSENT:
388
- record["responses"] = responses
429
+ if input_outcomes:
430
+ record["inputOutcomes"] = input_outcomes
389
431
  self._dispatch_record = record
390
432
  self._stop_reason = None
391
433
  self._run_acked = False
392
- self._leaf_confirmed = plain(self._state["runLeafMessageId"])
393
- self._state["error"] = None
434
+ entry = self._ensure_entry()
435
+ self._leaf_confirmed = plain(entry["runLeafMessageId"])
436
+ entry["error"] = None
394
437
  if messages:
395
438
  self._dispatched_ids = tuple(m["id"] for m in messages)
396
439
  if rollback_to is not _ABSENT:
397
- self._state["runLeafMessageId"] = rollback_to
440
+ entry["runLeafMessageId"] = rollback_to
398
441
  if messages:
399
- self._state["runLeafMessageId"] = messages[-1]["id"]
400
- self._state["status"] = "running"
401
- ctx = RunManager.StartContext(
402
- type=type,
442
+ entry["runLeafMessageId"] = messages[-1]["id"]
443
+ entry["runId"] = uuid.uuid4().hex
444
+ self._set_status("running")
445
+ ctx = RunManager.RunContext(
446
+ trigger=trigger,
403
447
  messages=tuple(messages),
404
448
  stop_requested=asyncio.Event(),
405
449
  _manager=self,
406
450
  _rollback_to=rollback_to,
407
- _responses=responses,
408
451
  _message_meta=message_meta,
409
452
  _root_meta=root_meta,
453
+ _input_outcomes=input_outcomes,
410
454
  )
411
455
  self._ctx = ctx
412
456
  self._sync_steering()
413
457
  self._idle.clear()
414
- self._task = self._create_task(self._run(ctx))
458
+ self._task = self._create_task(self._execute(ctx))
415
459
 
416
- async def _run(self, ctx: "RunManager.StartContext") -> None:
460
+ async def _execute(self, ctx: "RunManager.RunContext") -> None:
417
461
  try:
418
- outcome = await self._start(ctx)
462
+ outcome = await self._run(ctx)
419
463
  if not isinstance(
420
464
  outcome,
421
465
  (
@@ -426,7 +470,7 @@ class RunManager:
426
470
  ),
427
471
  ):
428
472
  raise TypeError(
429
- "start must return a RunManager outcome, got "
473
+ "run must return a RunManager outcome, got "
430
474
  f"{type(outcome).__name__}"
431
475
  )
432
476
  if isinstance(
@@ -437,7 +481,8 @@ class RunManager:
437
481
  )
438
482
  except asyncio.CancelledError:
439
483
  self._settle(ctx)
440
- self._state["status"] = "stopped"
484
+ self._set_status("stopped")
485
+ self._entry()["runId"] = None
441
486
  self._settle_initiators(_reject("stopped", "run cancelled"))
442
487
  self._revert_dispatching()
443
488
  self._idle.set()
@@ -466,7 +511,7 @@ class RunManager:
466
511
  self._outcome = outcome
467
512
  self._drain()
468
513
 
469
- def _settle(self, ctx: "RunManager.StartContext") -> None:
514
+ def _settle(self, ctx: "RunManager.RunContext") -> None:
470
515
  if self._ctx is ctx:
471
516
  self._ctx = None
472
517
  self._task = None
@@ -491,7 +536,7 @@ class RunManager:
491
536
  def _ack_messages(self) -> None:
492
537
  self._dispatching = []
493
538
  self._run_acked = True
494
- self._leaf_confirmed = plain(self._state["runLeafMessageId"])
539
+ self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
495
540
  self._settle_sends()
496
541
  self._settle_initiators(None)
497
542
 
@@ -500,38 +545,43 @@ class RunManager:
500
545
  taken, self._dispatching = self._dispatching, []
501
546
  if not taken:
502
547
  return
548
+ entry = self._ensure_entry()
503
549
  for lane in ("steerQueue", "queue"):
504
550
  front = [item for taken_lane, item in taken if taken_lane == lane]
505
551
  if front:
506
- self._state[lane] = front + self._lane_items(lane)
552
+ entry[lane] = front + self._lane_items(lane)
507
553
  ids = {item["id"] for _, item in taken}
508
554
  self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
509
- if plain(self._state["runLeafMessageId"]) in ids:
510
- self._state["runLeafMessageId"] = self._leaf_confirmed
555
+ if plain(entry["runLeafMessageId"]) in ids:
556
+ entry["runLeafMessageId"] = self._leaf_confirmed
511
557
 
512
- def _pop_dispatchable(self) -> bool:
558
+ def _pop_dispatchable(
559
+ self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
560
+ ) -> bool:
513
561
  steer = self._lane_items("steerQueue")
514
562
  if steer:
515
- self._state["steerQueue"] = []
563
+ self._entry()["steerQueue"] = []
516
564
  self._dispatching = [("steerQueue", item) for item in steer]
517
- self._dispatch("message-send", steer)
565
+ self._dispatch("message-send", steer, input_outcomes=input_outcomes)
518
566
  return True
519
567
  queue = self._lane_items("queue")
520
568
  if queue:
521
- self._state["queue"].pop(0)
569
+ self._entry()["queue"].pop(0)
522
570
  self._dispatching = [("queue", queue[0])]
523
- self._dispatch("message-send", [queue[0]])
571
+ self._dispatch("message-send", [queue[0]], input_outcomes=input_outcomes)
524
572
  return True
525
573
  return False
526
574
 
527
575
  def _freeze(self, message: str, payload: Any = None) -> None:
528
- self._state["status"] = "error"
576
+ self._set_status("error")
577
+ entry = self._entry()
578
+ entry["runId"] = None
529
579
  if isinstance(payload, dict):
530
- self._state["error"] = {**payload, "message": message}
580
+ entry["error"] = {**payload, "message": message}
531
581
  elif payload is not None:
532
- self._state["error"] = {"message": message, "payload": payload}
582
+ entry["error"] = {"message": message, "payload": payload}
533
583
  else:
534
- self._state["error"] = {"message": message}
584
+ entry["error"] = {"message": message}
535
585
 
536
586
  # ─── Message and placement validation ───────────────────
537
587
 
@@ -684,9 +734,10 @@ class RunManager:
684
734
  )
685
735
  items = list(base)
686
736
  items.insert(index, self._stamped(message, meta))
737
+ entry = self._entry()
687
738
  if lane_change:
688
- self._state[current] = without
689
- self._state[target] = items
739
+ entry[current] = without
740
+ entry[target] = items
690
741
  self._sync_steering()
691
742
 
692
743
  def _insert_new(
@@ -698,7 +749,7 @@ class RunManager:
698
749
  self._resolve_index(items, placement, len(items)),
699
750
  self._stamped(message, meta),
700
751
  )
701
- self._state[lane] = items
752
+ self._ensure_entry()[lane] = items
702
753
  self._sync_steering()
703
754
 
704
755
  # ─── Intake application ─────────────────────────────────
@@ -775,7 +826,7 @@ class RunManager:
775
826
  lane = self._lane_of(e.message_id)
776
827
  if lane is None:
777
828
  raise _reject("unknown-id", f"message {e.message_id} is not queued")
778
- self._state[lane] = [
829
+ self._entry()[lane] = [
779
830
  item for item in self._lane_items(lane) if item["id"] != e.message_id
780
831
  ]
781
832
  self._sync_steering()
@@ -800,7 +851,7 @@ class RunManager:
800
851
  f"input request {e.request_id} is already answered",
801
852
  )
802
853
  self._input_answers[e.request_id] = (e.response, e.meta)
803
- self._state["inputRequests"][index]["response"] = e.response
854
+ self._entry()["inputRequests"][index]["response"] = e.response
804
855
  return None
805
856
 
806
857
  def _apply_edit(self, e: _Edit) -> Any:
@@ -982,19 +1033,20 @@ class RunManager:
982
1033
  if reason is not _ABSENT and not isinstance(reason, str):
983
1034
  raise _reject("invalid-message", "reason must be a string")
984
1035
  status = self._status()
985
- if status == "running":
986
- if reason is not _ABSENT:
987
- self._stop_reason = reason
988
- entry = _Stop()
989
- self._intake.append(entry)
990
- ack()
991
- self._schedule(self._drain)
992
- return await entry.future
993
- if status == "input-required":
994
- self._clear_input()
995
- self._state["status"] = "stopped"
996
- return None
997
- raise _reject("wrong-state", f"run/stop is rejected in {status}")
1036
+ if status != "running":
1037
+ raise _reject("wrong-state", f"run/stop is rejected in {status}")
1038
+ run_id = (params or {}).get("runId")
1039
+ if run_id != plain(self._entry()["runId"]):
1040
+ raise _reject(
1041
+ "wrong-state", f"runId {run_id!r} does not name the live run"
1042
+ )
1043
+ if reason is not _ABSENT:
1044
+ self._stop_reason = reason
1045
+ entry = _Stop()
1046
+ self._intake.append(entry)
1047
+ ack()
1048
+ self._schedule(self._drain)
1049
+ return await entry.future
998
1050
 
999
1051
  def _validated_response(self, request_type: str, response: Any) -> dict[str, Any]:
1000
1052
  if not isinstance(response, dict):
@@ -1104,17 +1156,18 @@ class RunManager:
1104
1156
  @dataclass(frozen=True, eq=False)
1105
1157
  class Steering:
1106
1158
  available: asyncio.Event
1107
- _ctx: "RunManager.StartContext"
1159
+ _ctx: "RunManager.RunContext"
1108
1160
 
1109
1161
  def take(self) -> tuple[dict[str, Any], ...]:
1110
1162
  """Drain the steer lane into this run; items leave the projection, retained until the ack."""
1111
1163
  self._ctx._ensure_active()
1112
1164
  manager = self._ctx._manager
1113
1165
  items = manager._lane_items("steerQueue")
1114
- manager._state["steerQueue"] = []
1166
+ entry = manager._entry()
1167
+ entry["steerQueue"] = []
1115
1168
  manager._dispatching.extend(("steerQueue", item) for item in items)
1116
1169
  if items:
1117
- manager._state["runLeafMessageId"] = items[-1]["id"]
1170
+ entry["runLeafMessageId"] = items[-1]["id"]
1118
1171
  self._ctx._message_meta.update(
1119
1172
  {item["id"]: item.get("meta") for item in items}
1120
1173
  )
@@ -1124,15 +1177,15 @@ class RunManager:
1124
1177
  )
1125
1178
 
1126
1179
  @dataclass(frozen=True, eq=False)
1127
- class StartContext:
1128
- type: str
1180
+ class RunContext:
1181
+ trigger: str
1129
1182
  messages: tuple[dict[str, Any], ...]
1130
1183
  stop_requested: asyncio.Event
1131
1184
  _manager: "RunManager"
1132
1185
  _rollback_to: Any
1133
- _responses: Any
1134
1186
  _message_meta: dict[str, Any]
1135
1187
  _root_meta: Any
1188
+ _input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...]
1136
1189
  steering: "RunManager.Steering" = field(init=False)
1137
1190
 
1138
1191
  def __post_init__(self) -> None:
@@ -1148,6 +1201,11 @@ class RunManager:
1148
1201
  raise ValueError(f"message {message_id} was not dispatched to this run")
1149
1202
  return self._message_meta[message_id]
1150
1203
 
1204
+ @property
1205
+ def input_outcomes(self) -> tuple[tuple[dict[str, Any], Any, Any], ...]:
1206
+ """The consumed pending set's ordered (request, response, meta) triples; response None marks unanswered."""
1207
+ return self._input_outcomes
1208
+
1151
1209
  @property
1152
1210
  def stop_reason(self) -> str | None:
1153
1211
  return self._manager._stop_reason
@@ -1164,14 +1222,6 @@ class RunManager:
1164
1222
  )
1165
1223
  return self._rollback_to
1166
1224
 
1167
- @property
1168
- def responses(self) -> tuple[tuple[dict[str, Any], dict[str, Any], Any], ...]:
1169
- if self._responses is _ABSENT:
1170
- raise AttributeError(
1171
- "responses is only present on input-resume entries"
1172
- )
1173
- return self._responses
1174
-
1175
1225
  def _ensure_active(self) -> None:
1176
1226
  if self._manager._ctx is not self:
1177
1227
  raise RuntimeError("this run has already settled")
@@ -1186,7 +1236,7 @@ class RunManager:
1186
1236
  raise ValueError("message_id must be a non-empty string")
1187
1237
  if self._manager._dispatching or not self._manager._run_acked:
1188
1238
  raise RuntimeError("ack_messages must precede set_leaf_message_id")
1189
- self._manager._state["runLeafMessageId"] = message_id
1239
+ self._manager._entry()["runLeafMessageId"] = message_id
1190
1240
 
1191
1241
  def set_recovery_state(self, value: Any) -> None:
1192
1242
  self._ensure_active()
@@ -12,7 +12,7 @@ from harness_sdk import RunManager
12
12
 
13
13
  @dataclass
14
14
  class Call:
15
- ctx: RunManager.StartContext
15
+ ctx: RunManager.RunContext
16
16
  outcome: "asyncio.Future[Any]"
17
17
 
18
18
  def ack(self) -> None:
@@ -26,14 +26,14 @@ class Call:
26
26
 
27
27
 
28
28
  class Script:
29
- """Scripted executor: each start() parks as a Call the test settles, and
29
+ """Scripted executor: each run() parks as a Call the test settles, and
30
30
  ``thread`` is the message-meta table the executor is pretending to write."""
31
31
 
32
32
  def __init__(self) -> None:
33
33
  self.calls: asyncio.Queue[Call] = asyncio.Queue()
34
34
  self.thread: dict[str, dict[str, Any]] = {}
35
35
 
36
- async def start(self, ctx: RunManager.StartContext) -> Any:
36
+ async def run(self, ctx: RunManager.RunContext) -> Any:
37
37
  call = Call(ctx, asyncio.get_running_loop().create_future())
38
38
  self.calls.put_nowait(call)
39
39
  return await call.outcome
@@ -66,7 +66,7 @@ def make_host(script: Script, capabilities=(), initial_runs=None, max_queued=50)
66
66
  )
67
67
  self.runs = RunManager(
68
68
  state=self.state,
69
- start=script.start,
69
+ run=script.run,
70
70
  get_message_meta=script.get_message_meta,
71
71
  create_task=self.create_task,
72
72
  schedule=self.schedule,
@@ -191,6 +191,31 @@ class RunDriver:
191
191
  lambda s: s["status"] in statuses, timeout=timeout
192
192
  )
193
193
 
194
+ @property
195
+ def run(self) -> dict[str, Any]:
196
+ """The single run entry; asserts one exists."""
197
+ runs = self.replica["runs"]
198
+ assert runs, "no run entry in the replica"
199
+ return runs[0]
200
+
201
+ async def run_id(self, timeout: float = 5) -> str:
202
+ """The live run's id; waits for the replica to catch up to the dispatch."""
203
+ await self.wait(
204
+ lambda s: bool(s["runs"]) and s["runs"][0]["runId"] is not None,
205
+ timeout=timeout,
206
+ )
207
+ return self.run["runId"]
208
+
209
+ async def stop(
210
+ self, params: Any = None, *, terminal: bool = True
211
+ ) -> dict[str, Any]:
212
+ """run/stop targeting the live run's id read from the replica."""
213
+ return await self.command(
214
+ "run/stop",
215
+ {"runId": await self.run_id(), **(params or {})},
216
+ terminal=terminal,
217
+ )
218
+
194
219
 
195
220
  @asynccontextmanager
196
221
  async def run_host(script: Script, capabilities=(), initial_runs=None, max_queued=50):
@@ -222,5 +247,12 @@ def assert_rejected(rsp: dict[str, Any], reason: str) -> None:
222
247
  assert rsp["payload"] == {"reason": reason}, rsp
223
248
 
224
249
 
225
- def queue_ids(runs: dict[str, Any], lane: str) -> list[str]:
226
- return [item["id"] for item in runs[lane]]
250
+ def run_of(replica: dict[str, Any]) -> dict[str, Any]:
251
+ """The run entry of a replica snapshot; empty dict while resting."""
252
+ runs = replica["runs"]
253
+ return runs[0] if runs else {}
254
+
255
+
256
+ def queue_ids(replica: dict[str, Any], lane: str) -> list[str]:
257
+ runs = replica["runs"]
258
+ return [item["id"] for item in runs[0][lane]] if runs else []
@@ -23,7 +23,7 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
23
23
  for offset in range(3):
24
24
  assert (await drv.res(first + offset, terminal=False))["type"] == "pending"
25
25
  call = await script.next_call()
26
- assert call.ctx.type == "message-send"
26
+ assert call.ctx.trigger == "message-send"
27
27
  assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
28
28
  script.no_call()
29
29
  assert queue_ids(drv.replica, "steerQueue") == []
@@ -39,7 +39,7 @@ async def test_steer_and_stop_in_one_batch_nets_to_stop():
39
39
  await drv.command("run/enqueue", add("m1"), terminal=False)
40
40
  call = await script.next_call()
41
41
  first = await drv.batch(
42
- [("run/steer", add("s1")), ("run/stop", None)]
42
+ [("run/steer", add("s1")), ("run/stop", {"runId": await drv.run_id()})]
43
43
  )
44
44
  assert (await drv.res(first))["type"] == "accepted"
45
45
  assert (await drv.res(first + 1, terminal=False))["type"] == "pending"
@@ -56,7 +56,7 @@ async def test_stop_settles_only_after_the_run_task_ends():
56
56
  async with run_host(script) as (drv, host):
57
57
  await drv.command("run/enqueue", add("m1"), terminal=False)
58
58
  call = await script.next_call()
59
- pending = await drv.command("run/stop", terminal=False)
59
+ pending = await drv.stop(terminal=False)
60
60
  assert pending["type"] == "pending"
61
61
  for _ in range(5):
62
62
  await asyncio.sleep(0)