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.
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.4.4 → 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.4 → harness_sdk_python-0.5.0}/src/harness_sdk/run_manager.py +146 -96
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/run_helpers.py +38 -6
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_batches.py +3 -3
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_branch_anchor.py +2 -2
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_edit_dispatched.py +5 -5
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_edit_reload.py +8 -8
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_enqueue.py +8 -8
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_facade.py +2 -2
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_input_required.py +126 -32
- harness_sdk_python-0.5.0/tests/test_linear_thread.py +87 -0
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_meta.py +18 -18
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_outcomes.py +7 -13
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_rewind_during_run.py +7 -7
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_run_leaf.py +16 -16
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_settle.py +9 -9
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_steer.py +6 -6
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_stop_continue.py +82 -13
- harness_sdk_python-0.4.4/src/harness_sdk/__init__.py +0 -3
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/.gitignore +0 -0
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/README.md +0 -0
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.4.4 → harness_sdk_python-0.5.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.4.4 → 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"):
|
|
@@ -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
|
-
|
|
331
|
-
|
|
332
|
-
and len(self._input_answers) == len(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
377
|
-
raise ValueError(f"invalid
|
|
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] = {"
|
|
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
|
|
388
|
-
record["
|
|
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
|
-
|
|
393
|
-
self.
|
|
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
|
-
|
|
440
|
+
entry["runLeafMessageId"] = rollback_to
|
|
398
441
|
if messages:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
458
|
+
self._task = self._create_task(self._execute(ctx))
|
|
415
459
|
|
|
416
|
-
async def
|
|
460
|
+
async def _execute(self, ctx: "RunManager.RunContext") -> None:
|
|
417
461
|
try:
|
|
418
|
-
outcome = await self.
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
510
|
-
|
|
555
|
+
if plain(entry["runLeafMessageId"]) in ids:
|
|
556
|
+
entry["runLeafMessageId"] = self._leaf_confirmed
|
|
511
557
|
|
|
512
|
-
def _pop_dispatchable(
|
|
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.
|
|
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.
|
|
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.
|
|
576
|
+
self._set_status("error")
|
|
577
|
+
entry = self._entry()
|
|
578
|
+
entry["runId"] = None
|
|
529
579
|
if isinstance(payload, dict):
|
|
530
|
-
|
|
580
|
+
entry["error"] = {**payload, "message": message}
|
|
531
581
|
elif payload is not None:
|
|
532
|
-
|
|
582
|
+
entry["error"] = {"message": message, "payload": payload}
|
|
533
583
|
else:
|
|
534
|
-
|
|
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
|
-
|
|
689
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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.
|
|
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
|
-
|
|
1166
|
+
entry = manager._entry()
|
|
1167
|
+
entry["steerQueue"] = []
|
|
1115
1168
|
manager._dispatching.extend(("steerQueue", item) for item in items)
|
|
1116
1169
|
if items:
|
|
1117
|
-
|
|
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
|
|
1128
|
-
|
|
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.
|
|
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.
|
|
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)
|