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.
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/pyproject.toml +1 -1
- harness_sdk_python-0.5.0/src/harness_sdk/__init__.py +4 -0
- harness_sdk_python-0.5.0/src/harness_sdk/linear_thread.py +31 -0
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/src/harness_sdk/run_manager.py +136 -95
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/run_helpers.py +38 -6
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_batches.py +3 -3
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_branch_anchor.py +2 -2
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_edit_dispatched.py +5 -5
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_edit_reload.py +8 -8
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_enqueue.py +8 -8
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_facade.py +2 -2
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_input_required.py +57 -39
- harness_sdk_python-0.5.0/tests/test_linear_thread.py +87 -0
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_meta.py +18 -18
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_outcomes.py +7 -13
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_rewind_during_run.py +7 -7
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_run_leaf.py +16 -16
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_settle.py +9 -9
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_steer.py +6 -6
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_stop_continue.py +81 -12
- harness_sdk_python-0.4.5/src/harness_sdk/__init__.py +0 -3
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/.gitignore +0 -0
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/README.md +0 -0
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.4.5 → harness_sdk_python-0.5.0}/tests/test_placement.py +0 -0
|
@@ -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
|
-
``
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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["
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
416
|
+
input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = (),
|
|
379
417
|
) -> None:
|
|
380
|
-
if
|
|
381
|
-
raise ValueError(f"invalid
|
|
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] = {"
|
|
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
|
|
392
|
-
record["
|
|
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
|
-
|
|
397
|
-
self.
|
|
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
|
-
|
|
440
|
+
entry["runLeafMessageId"] = rollback_to
|
|
402
441
|
if messages:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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.
|
|
458
|
+
self._task = self._create_task(self._execute(ctx))
|
|
420
459
|
|
|
421
|
-
async def
|
|
460
|
+
async def _execute(self, ctx: "RunManager.RunContext") -> None:
|
|
422
461
|
try:
|
|
423
|
-
outcome = await self.
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
515
|
-
|
|
555
|
+
if plain(entry["runLeafMessageId"]) in ids:
|
|
556
|
+
entry["runLeafMessageId"] = self._leaf_confirmed
|
|
516
557
|
|
|
517
558
|
def _pop_dispatchable(
|
|
518
|
-
self, *,
|
|
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.
|
|
563
|
+
self._entry()["steerQueue"] = []
|
|
523
564
|
self._dispatching = [("steerQueue", item) for item in steer]
|
|
524
|
-
self._dispatch("message-send", steer,
|
|
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.
|
|
569
|
+
self._entry()["queue"].pop(0)
|
|
529
570
|
self._dispatching = [("queue", queue[0])]
|
|
530
|
-
self._dispatch("message-send", [queue[0]],
|
|
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.
|
|
576
|
+
self._set_status("error")
|
|
577
|
+
entry = self._entry()
|
|
578
|
+
entry["runId"] = None
|
|
536
579
|
if isinstance(payload, dict):
|
|
537
|
-
|
|
580
|
+
entry["error"] = {**payload, "message": message}
|
|
538
581
|
elif payload is not None:
|
|
539
|
-
|
|
582
|
+
entry["error"] = {"message": message, "payload": payload}
|
|
540
583
|
else:
|
|
541
|
-
|
|
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
|
-
|
|
696
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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.
|
|
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
|
-
|
|
1166
|
+
entry = manager._entry()
|
|
1167
|
+
entry["steerQueue"] = []
|
|
1118
1168
|
manager._dispatching.extend(("steerQueue", item) for item in items)
|
|
1119
1169
|
if items:
|
|
1120
|
-
|
|
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
|
|
1131
|
-
|
|
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
|
-
|
|
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
|
|
1157
|
-
"""
|
|
1158
|
-
return self.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
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.
|
|
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",
|
|
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.
|
|
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)
|