harness-sdk-python 0.4.5__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.5 → harness_sdk_python-0.5.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.4.5 → 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.5 → harness_sdk_python-0.5.0}/src/harness_sdk/run_manager.py +136 -95
  6. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/run_helpers.py +38 -6
  7. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_batches.py +3 -3
  8. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_branch_anchor.py +2 -2
  9. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_edit_dispatched.py +5 -5
  10. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_edit_reload.py +8 -8
  11. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_enqueue.py +8 -8
  12. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_facade.py +2 -2
  13. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_input_required.py +57 -39
  14. harness_sdk_python-0.5.0/tests/test_linear_thread.py +87 -0
  15. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_meta.py +18 -18
  16. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_outcomes.py +7 -13
  17. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_rewind_during_run.py +7 -7
  18. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_run_leaf.py +16 -16
  19. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_settle.py +9 -9
  20. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_steer.py +6 -6
  21. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_stop_continue.py +81 -12
  22. harness_sdk_python-0.4.5/src/harness_sdk/__init__.py +0 -3
  23. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/.gitignore +0 -0
  24. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/README.md +0 -0
  25. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/src/harness_sdk/fenced_postgres.py +0 -0
  26. {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_fenced_postgres.py +0 -0
  27. {harness_sdk_python-0.4.5 → 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.5
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.5"
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"):
@@ -328,99 +361,105 @@ class RunManager:
328
361
  self._pop_dispatchable()
329
362
  elif status == "input-required":
330
363
  if fx.steer_added and self._lane_items("steerQueue"):
331
- abandoned = tuple(self._input_requests)
332
- self._clear_input()
333
- self._pop_dispatchable(abandoned_input=abandoned)
364
+ self._pop_dispatchable(input_outcomes=self._take_input_outcomes())
334
365
  elif self._input_requests and len(self._input_answers) == len(
335
366
  self._input_requests
336
367
  ):
337
- responses = tuple(
338
- (request, *self._input_answers[request["id"]])
339
- for request in self._input_requests
368
+ self._dispatch(
369
+ "input-resume", [], input_outcomes=self._take_input_outcomes()
340
370
  )
341
- self._clear_input()
342
- self._dispatch("input-resume", [], responses=responses)
343
371
 
344
372
  def _settle_outcome(self, outcome: Any) -> None:
345
373
  if isinstance(outcome, RunManager.Complete):
346
374
  if not self._pop_dispatchable():
347
- self._state["status"] = "ready"
348
- self._state["runLeafMessageId"] = None
375
+ self._to_ready()
349
376
  return
350
377
  if isinstance(outcome, RunManager.InputRequired):
351
- self._state["status"] = "input-required"
352
378
  self._input_requests = [dict(r) for r in outcome.requests]
353
379
  self._input_answers = {}
354
- self._state["inputRequests"] = [
380
+ entry = self._entry()
381
+ entry["inputRequests"] = [
355
382
  {**r, "response": None} for r in outcome.requests
356
383
  ]
384
+ self._set_status("input-required")
357
385
  return
358
386
  status = "error" if isinstance(outcome, RunManager.Error) else "stopped"
359
387
  if outcome.dispatch_queue and self._pop_dispatchable():
360
388
  return
361
- self._state["status"] = status
389
+ self._set_status(status)
390
+ self._entry()["runId"] = None
362
391
 
363
392
  # ─── Dispatch and settle ────────────────────────────────
364
393
 
365
394
  def _clear_input(self) -> None:
366
395
  self._input_requests = []
367
396
  self._input_answers = {}
368
- 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
369
408
 
370
409
  def _dispatch(
371
410
  self,
372
- type: str,
411
+ trigger: str,
373
412
  messages: list[dict[str, Any]],
374
413
  *,
375
414
  rollback_to: Any = _ABSENT,
376
- responses: Any = _ABSENT,
377
415
  root_meta: Any = None,
378
- abandoned_input: tuple[dict[str, Any], ...] = (),
416
+ input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = (),
379
417
  ) -> None:
380
- if type not in _ENTRY_TYPES:
381
- raise ValueError(f"invalid entry type: {type!r}")
418
+ if trigger not in _TRIGGERS:
419
+ raise ValueError(f"invalid trigger: {trigger!r}")
382
420
  if self._task is not None:
383
421
  raise RuntimeError("a run is already in flight")
384
422
  message_meta = {m["id"]: m.get("meta") for m in messages}
385
423
  messages = [
386
424
  {k: v for k, v in message.items() if k != "meta"} for message in messages
387
425
  ]
388
- record: dict[str, Any] = {"type": type, "messages": list(messages)}
426
+ record: dict[str, Any] = {"trigger": trigger, "messages": list(messages)}
389
427
  if rollback_to is not _ABSENT:
390
428
  record["rollbackTo"] = rollback_to
391
- if responses is not _ABSENT:
392
- record["responses"] = responses
429
+ if input_outcomes:
430
+ record["inputOutcomes"] = input_outcomes
393
431
  self._dispatch_record = record
394
432
  self._stop_reason = None
395
433
  self._run_acked = False
396
- self._leaf_confirmed = plain(self._state["runLeafMessageId"])
397
- self._state["error"] = None
434
+ entry = self._ensure_entry()
435
+ self._leaf_confirmed = plain(entry["runLeafMessageId"])
436
+ entry["error"] = None
398
437
  if messages:
399
438
  self._dispatched_ids = tuple(m["id"] for m in messages)
400
439
  if rollback_to is not _ABSENT:
401
- self._state["runLeafMessageId"] = rollback_to
440
+ entry["runLeafMessageId"] = rollback_to
402
441
  if messages:
403
- self._state["runLeafMessageId"] = messages[-1]["id"]
404
- self._state["status"] = "running"
405
- ctx = RunManager.StartContext(
406
- 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,
407
447
  messages=tuple(messages),
408
448
  stop_requested=asyncio.Event(),
409
449
  _manager=self,
410
450
  _rollback_to=rollback_to,
411
- _responses=responses,
412
451
  _message_meta=message_meta,
413
452
  _root_meta=root_meta,
414
- _abandoned_input=abandoned_input,
453
+ _input_outcomes=input_outcomes,
415
454
  )
416
455
  self._ctx = ctx
417
456
  self._sync_steering()
418
457
  self._idle.clear()
419
- self._task = self._create_task(self._run(ctx))
458
+ self._task = self._create_task(self._execute(ctx))
420
459
 
421
- async def _run(self, ctx: "RunManager.StartContext") -> None:
460
+ async def _execute(self, ctx: "RunManager.RunContext") -> None:
422
461
  try:
423
- outcome = await self._start(ctx)
462
+ outcome = await self._run(ctx)
424
463
  if not isinstance(
425
464
  outcome,
426
465
  (
@@ -431,7 +470,7 @@ class RunManager:
431
470
  ),
432
471
  ):
433
472
  raise TypeError(
434
- "start must return a RunManager outcome, got "
473
+ "run must return a RunManager outcome, got "
435
474
  f"{type(outcome).__name__}"
436
475
  )
437
476
  if isinstance(
@@ -442,7 +481,8 @@ class RunManager:
442
481
  )
443
482
  except asyncio.CancelledError:
444
483
  self._settle(ctx)
445
- self._state["status"] = "stopped"
484
+ self._set_status("stopped")
485
+ self._entry()["runId"] = None
446
486
  self._settle_initiators(_reject("stopped", "run cancelled"))
447
487
  self._revert_dispatching()
448
488
  self._idle.set()
@@ -471,7 +511,7 @@ class RunManager:
471
511
  self._outcome = outcome
472
512
  self._drain()
473
513
 
474
- def _settle(self, ctx: "RunManager.StartContext") -> None:
514
+ def _settle(self, ctx: "RunManager.RunContext") -> None:
475
515
  if self._ctx is ctx:
476
516
  self._ctx = None
477
517
  self._task = None
@@ -496,7 +536,7 @@ class RunManager:
496
536
  def _ack_messages(self) -> None:
497
537
  self._dispatching = []
498
538
  self._run_acked = True
499
- self._leaf_confirmed = plain(self._state["runLeafMessageId"])
539
+ self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
500
540
  self._settle_sends()
501
541
  self._settle_initiators(None)
502
542
 
@@ -505,40 +545,43 @@ class RunManager:
505
545
  taken, self._dispatching = self._dispatching, []
506
546
  if not taken:
507
547
  return
548
+ entry = self._ensure_entry()
508
549
  for lane in ("steerQueue", "queue"):
509
550
  front = [item for taken_lane, item in taken if taken_lane == lane]
510
551
  if front:
511
- self._state[lane] = front + self._lane_items(lane)
552
+ entry[lane] = front + self._lane_items(lane)
512
553
  ids = {item["id"] for _, item in taken}
513
554
  self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
514
- if plain(self._state["runLeafMessageId"]) in ids:
515
- self._state["runLeafMessageId"] = self._leaf_confirmed
555
+ if plain(entry["runLeafMessageId"]) in ids:
556
+ entry["runLeafMessageId"] = self._leaf_confirmed
516
557
 
517
558
  def _pop_dispatchable(
518
- self, *, abandoned_input: tuple[dict[str, Any], ...] = ()
559
+ self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
519
560
  ) -> bool:
520
561
  steer = self._lane_items("steerQueue")
521
562
  if steer:
522
- self._state["steerQueue"] = []
563
+ self._entry()["steerQueue"] = []
523
564
  self._dispatching = [("steerQueue", item) for item in steer]
524
- self._dispatch("message-send", steer, abandoned_input=abandoned_input)
565
+ self._dispatch("message-send", steer, input_outcomes=input_outcomes)
525
566
  return True
526
567
  queue = self._lane_items("queue")
527
568
  if queue:
528
- self._state["queue"].pop(0)
569
+ self._entry()["queue"].pop(0)
529
570
  self._dispatching = [("queue", queue[0])]
530
- self._dispatch("message-send", [queue[0]], abandoned_input=abandoned_input)
571
+ self._dispatch("message-send", [queue[0]], input_outcomes=input_outcomes)
531
572
  return True
532
573
  return False
533
574
 
534
575
  def _freeze(self, message: str, payload: Any = None) -> None:
535
- self._state["status"] = "error"
576
+ self._set_status("error")
577
+ entry = self._entry()
578
+ entry["runId"] = None
536
579
  if isinstance(payload, dict):
537
- self._state["error"] = {**payload, "message": message}
580
+ entry["error"] = {**payload, "message": message}
538
581
  elif payload is not None:
539
- self._state["error"] = {"message": message, "payload": payload}
582
+ entry["error"] = {"message": message, "payload": payload}
540
583
  else:
541
- self._state["error"] = {"message": message}
584
+ entry["error"] = {"message": message}
542
585
 
543
586
  # ─── Message and placement validation ───────────────────
544
587
 
@@ -691,9 +734,10 @@ class RunManager:
691
734
  )
692
735
  items = list(base)
693
736
  items.insert(index, self._stamped(message, meta))
737
+ entry = self._entry()
694
738
  if lane_change:
695
- self._state[current] = without
696
- self._state[target] = items
739
+ entry[current] = without
740
+ entry[target] = items
697
741
  self._sync_steering()
698
742
 
699
743
  def _insert_new(
@@ -705,7 +749,7 @@ class RunManager:
705
749
  self._resolve_index(items, placement, len(items)),
706
750
  self._stamped(message, meta),
707
751
  )
708
- self._state[lane] = items
752
+ self._ensure_entry()[lane] = items
709
753
  self._sync_steering()
710
754
 
711
755
  # ─── Intake application ─────────────────────────────────
@@ -782,7 +826,7 @@ class RunManager:
782
826
  lane = self._lane_of(e.message_id)
783
827
  if lane is None:
784
828
  raise _reject("unknown-id", f"message {e.message_id} is not queued")
785
- self._state[lane] = [
829
+ self._entry()[lane] = [
786
830
  item for item in self._lane_items(lane) if item["id"] != e.message_id
787
831
  ]
788
832
  self._sync_steering()
@@ -807,7 +851,7 @@ class RunManager:
807
851
  f"input request {e.request_id} is already answered",
808
852
  )
809
853
  self._input_answers[e.request_id] = (e.response, e.meta)
810
- self._state["inputRequests"][index]["response"] = e.response
854
+ self._entry()["inputRequests"][index]["response"] = e.response
811
855
  return None
812
856
 
813
857
  def _apply_edit(self, e: _Edit) -> Any:
@@ -989,15 +1033,20 @@ class RunManager:
989
1033
  if reason is not _ABSENT and not isinstance(reason, str):
990
1034
  raise _reject("invalid-message", "reason must be a string")
991
1035
  status = self._status()
992
- if status == "running":
993
- if reason is not _ABSENT:
994
- self._stop_reason = reason
995
- entry = _Stop()
996
- self._intake.append(entry)
997
- ack()
998
- self._schedule(self._drain)
999
- return await entry.future
1000
- 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
1001
1050
 
1002
1051
  def _validated_response(self, request_type: str, response: Any) -> dict[str, Any]:
1003
1052
  if not isinstance(response, dict):
@@ -1107,17 +1156,18 @@ class RunManager:
1107
1156
  @dataclass(frozen=True, eq=False)
1108
1157
  class Steering:
1109
1158
  available: asyncio.Event
1110
- _ctx: "RunManager.StartContext"
1159
+ _ctx: "RunManager.RunContext"
1111
1160
 
1112
1161
  def take(self) -> tuple[dict[str, Any], ...]:
1113
1162
  """Drain the steer lane into this run; items leave the projection, retained until the ack."""
1114
1163
  self._ctx._ensure_active()
1115
1164
  manager = self._ctx._manager
1116
1165
  items = manager._lane_items("steerQueue")
1117
- manager._state["steerQueue"] = []
1166
+ entry = manager._entry()
1167
+ entry["steerQueue"] = []
1118
1168
  manager._dispatching.extend(("steerQueue", item) for item in items)
1119
1169
  if items:
1120
- manager._state["runLeafMessageId"] = items[-1]["id"]
1170
+ entry["runLeafMessageId"] = items[-1]["id"]
1121
1171
  self._ctx._message_meta.update(
1122
1172
  {item["id"]: item.get("meta") for item in items}
1123
1173
  )
@@ -1127,16 +1177,15 @@ class RunManager:
1127
1177
  )
1128
1178
 
1129
1179
  @dataclass(frozen=True, eq=False)
1130
- class StartContext:
1131
- type: str
1180
+ class RunContext:
1181
+ trigger: str
1132
1182
  messages: tuple[dict[str, Any], ...]
1133
1183
  stop_requested: asyncio.Event
1134
1184
  _manager: "RunManager"
1135
1185
  _rollback_to: Any
1136
- _responses: Any
1137
1186
  _message_meta: dict[str, Any]
1138
1187
  _root_meta: Any
1139
- _abandoned_input: tuple[dict[str, Any], ...]
1188
+ _input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...]
1140
1189
  steering: "RunManager.Steering" = field(init=False)
1141
1190
 
1142
1191
  def __post_init__(self) -> None:
@@ -1153,9 +1202,9 @@ class RunManager:
1153
1202
  return self._message_meta[message_id]
1154
1203
 
1155
1204
  @property
1156
- def abandoned_input(self) -> tuple[dict[str, Any], ...]:
1157
- """Input requests pending when a dispatching send abandoned them; empty on every other start."""
1158
- return self._abandoned_input
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
1159
1208
 
1160
1209
  @property
1161
1210
  def stop_reason(self) -> str | None:
@@ -1173,14 +1222,6 @@ class RunManager:
1173
1222
  )
1174
1223
  return self._rollback_to
1175
1224
 
1176
- @property
1177
- def responses(self) -> tuple[tuple[dict[str, Any], dict[str, Any], Any], ...]:
1178
- if self._responses is _ABSENT:
1179
- raise AttributeError(
1180
- "responses is only present on input-resume entries"
1181
- )
1182
- return self._responses
1183
-
1184
1225
  def _ensure_active(self) -> None:
1185
1226
  if self._manager._ctx is not self:
1186
1227
  raise RuntimeError("this run has already settled")
@@ -1195,7 +1236,7 @@ class RunManager:
1195
1236
  raise ValueError("message_id must be a non-empty string")
1196
1237
  if self._manager._dispatching or not self._manager._run_acked:
1197
1238
  raise RuntimeError("ack_messages must precede set_leaf_message_id")
1198
- self._manager._state["runLeafMessageId"] = message_id
1239
+ self._manager._entry()["runLeafMessageId"] = message_id
1199
1240
 
1200
1241
  def set_recovery_state(self, value: Any) -> None:
1201
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)