@steerable/runtime-darwin-arm64 0.10.3 → 0.11.0

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 (29) hide show
  1. package/package.json +1 -1
  2. package/python-runtime/manifest.json +1 -1
  3. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.10.3.dist-info → steerable_agent_harness-0.11.0.dist-info}/METADATA +1 -1
  4. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.10.3.dist-info → steerable_agent_harness-0.11.0.dist-info}/RECORD +7 -7
  5. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.10.3.dist-info → steerable_agent_protocol-0.11.0.dist-info}/METADATA +1 -1
  6. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.11.0.dist-info/RECORD +11 -0
  7. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/__init__.py +12 -0
  8. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/orchestration.py +374 -35
  9. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/peer.py +192 -0
  10. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/pool.py +96 -2
  11. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/subagent.py +27 -8
  12. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/team_board.py +229 -0
  13. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/tree.py +155 -0
  14. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.10.3.dist-info → steerable_agent_runtime-0.11.0.dist-info}/METADATA +32 -1
  15. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.10.3.dist-info → steerable_agent_runtime-0.11.0.dist-info}/RECORD +17 -11
  16. package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar/sidecar.py +49 -1
  17. package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/METADATA +25 -1
  18. package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/RECORD +9 -9
  19. package/runtime.json +44 -32
  20. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.10.3.dist-info/RECORD +0 -11
  21. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.10.3.dist-info → steerable_agent_harness-0.11.0.dist-info}/INSTALLER +0 -0
  22. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.10.3.dist-info → steerable_agent_harness-0.11.0.dist-info}/WHEEL +0 -0
  23. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.10.3.dist-info → steerable_agent_protocol-0.11.0.dist-info}/INSTALLER +0 -0
  24. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.10.3.dist-info → steerable_agent_protocol-0.11.0.dist-info}/WHEEL +0 -0
  25. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.10.3.dist-info → steerable_agent_runtime-0.11.0.dist-info}/INSTALLER +0 -0
  26. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.10.3.dist-info → steerable_agent_runtime-0.11.0.dist-info}/WHEEL +0 -0
  27. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/INSTALLER +0 -0
  28. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/WHEEL +0 -0
  29. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,192 @@
1
+ """Tree-wide messaging, as a tool layer any agent in the job can carry.
2
+
3
+ The spawn tree (``tree.py``) decides who is reachable; this module is what
4
+ puts that decision in front of a model. It lives apart from
5
+ ``orchestration.py`` because messaging and delegating are different
6
+ permissions that happen to share a tree:
7
+
8
+ - delegating is bounded by depth — a ``max_depth=1`` child must not spawn;
9
+ - messaging is not — the whole point of a shared tree is that a worker can
10
+ hand a finding to a sibling or answer the agent that spawned it.
11
+
12
+ Two surfaces carry it. ``OrchestrationExecutor`` answers these names where it
13
+ advertises the full family; ``PeerToolsExecutor`` wraps a child that gets no
14
+ family at all (a depth-limited worker, or a ``delegate_subagent`` child),
15
+ giving it exactly ``agent_send`` and ``agent_list``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Any
22
+
23
+ from steerable_agent_protocol.generated import ToolCall, ToolResult
24
+
25
+ from .loop import LoopContext, ToolExecutor
26
+ from .pool import OrchestrationConfig
27
+ from .tree import AgentTree
28
+
29
+ __all__ = [
30
+ "PeerToolsExecutor",
31
+ "peer_schemas",
32
+ "send_tool_descriptor",
33
+ "tool_schema",
34
+ ]
35
+
36
+
37
+ def tool_schema(
38
+ name: str, description: str, properties: dict, required: list[str]
39
+ ) -> dict[str, Any]:
40
+ """One OpenAI function schema.
41
+
42
+ Shared by the three tool families so their JSON has one shape — a host
43
+ that strict-validates ``additionalProperties: false`` should not have to
44
+ special-case which family a schema came from.
45
+ """
46
+ return {
47
+ "type": "function",
48
+ "function": {
49
+ "name": name,
50
+ "description": description,
51
+ "parameters": {
52
+ "type": "object",
53
+ "properties": properties,
54
+ "required": required,
55
+ "additionalProperties": False,
56
+ },
57
+ },
58
+ }
59
+
60
+
61
+ def send_tool_descriptor(config: OrchestrationConfig) -> dict[str, Any]:
62
+ """Schema for ``agent_send``, described as tree-wide rather than downward.
63
+
64
+ The description is the documentation a model actually reads: it has to say
65
+ that a sibling and the root are addressable, or a worker will keep routing
66
+ every fact through the parent — the behaviour this slice exists to remove.
67
+ """
68
+ return tool_schema(
69
+ config.send_tool,
70
+ "Send a message to another agent in this job. A running agent "
71
+ "receives it at its next round boundary (steer); a finished or "
72
+ "interrupted one is resumed with the message as a follow-up turn, "
73
+ "keeping its prior context. Any member of the job is addressable by "
74
+ "lineage id — `0` is the agent that started it, `0.2` a sibling, "
75
+ "`0.2.1` a child — so workers can hand facts to each other instead of "
76
+ "routing everything through the parent. Call agent_list to see the "
77
+ "ids. Closed agents reject sends.",
78
+ {
79
+ "childId": {
80
+ "type": "string",
81
+ "description": "Lineage id of the target agent (`0`, `0.2`, `0.2.1`).",
82
+ },
83
+ "message": {"type": "string"},
84
+ },
85
+ ["childId", "message"],
86
+ )
87
+
88
+
89
+ def peer_schemas(config: OrchestrationConfig) -> list[dict[str, Any]]:
90
+ """The two schemas a leaf agent is advertised: send and list."""
91
+ return [
92
+ send_tool_descriptor(config),
93
+ tool_schema(
94
+ config.list_tool,
95
+ "List the agents in this job with their lineage id, depth and "
96
+ "profile — the ids `agent_send` accepts. `self` is your own id.",
97
+ {},
98
+ [],
99
+ ),
100
+ ]
101
+
102
+
103
+ class PeerToolsExecutor:
104
+ """ToolExecutor decorator: ``agent_send`` / ``agent_list`` over a tree.
105
+
106
+ For an agent that has no orchestration family — a depth-limited worker, or
107
+ a ``delegate_subagent`` child — and therefore no other way to speak to a
108
+ peer. Nested agents do not use it: their ``OrchestrationExecutor`` already
109
+ answers both names, and wrapping twice would answer once too often.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ inner: ToolExecutor,
115
+ tree: AgentTree,
116
+ config: OrchestrationConfig,
117
+ *,
118
+ lineage: str,
119
+ ) -> None:
120
+ self._inner = inner
121
+ self._tree = tree
122
+ self._config = config
123
+ #: Who this agent is on the tree, so the tree can refuse a self-send
124
+ #: and the result can name the sender.
125
+ self._lineage = lineage
126
+
127
+ async def execute(self, call: ToolCall, ctx: LoopContext) -> ToolResult:
128
+ if call.name == self._config.send_tool:
129
+ return await self._send(call)
130
+ if call.name == self._config.list_tool:
131
+ return self._list()
132
+ return await self._inner.execute(call, ctx)
133
+
134
+ async def _send(self, call: ToolCall) -> ToolResult:
135
+ target = str(call.arguments.get("childId") or "").strip()
136
+ message = str(call.arguments.get("message") or "").strip()
137
+ if not message:
138
+ return ToolResult(success=False, error="empty message")
139
+ # The tree owns reachability: its refusal already names every address
140
+ # this agent has (and covers a self-send), which is what lets a model
141
+ # that guessed an id correct itself in the same turn.
142
+ verdict = await self._tree.send(self._lineage, target, message)
143
+ if verdict.startswith("error:"):
144
+ return ToolResult(
145
+ success=False,
146
+ error=verdict[len("error:"):],
147
+ needsFollowup=True,
148
+ )
149
+ member = self._tree.get(target)
150
+ return ToolResult(
151
+ success=True,
152
+ message=json.dumps(
153
+ {
154
+ "childId": target,
155
+ "delivery": verdict,
156
+ **({"depth": member.depth} if member is not None else {}),
157
+ }
158
+ ),
159
+ data={"childId": target, "delivery": verdict},
160
+ )
161
+
162
+ def _list(self) -> ToolResult:
163
+ """Every live member, with this agent's own id.
164
+
165
+ ``children`` is present and empty so a leaf and a nested agent answer
166
+ the same tool with the same keys; a host that reads only that key sees
167
+ an honest "none", not a missing field.
168
+ """
169
+ members = [member.to_wire() for member in self._tree.members()]
170
+ return ToolResult(
171
+ success=True,
172
+ message=json.dumps(
173
+ {"children": [], "members": members, "self": self._lineage}
174
+ ),
175
+ data={"count": 0},
176
+ )
177
+
178
+ def dedup_exempt(self, call: ToolCall) -> bool:
179
+ # A send is stateful: the same message twice is two deliveries, and a
180
+ # repeated list must observe what a send changed.
181
+ if call.name in {self._config.send_tool, self._config.list_tool}:
182
+ return True
183
+ inner_exempt = getattr(self._inner, "dedup_exempt", None)
184
+ return bool(inner_exempt and inner_exempt(call))
185
+
186
+ def concurrency_safe(self, call: ToolCall) -> bool:
187
+ # A send mutates another agent's inbox and, on resume, awaits its
188
+ # first step; serial execution keeps the order the model emitted.
189
+ if call.name in {self._config.send_tool, self._config.list_tool}:
190
+ return False
191
+ inner_safe = getattr(self._inner, "concurrency_safe", None)
192
+ return bool(inner_safe and inner_safe(call))
@@ -54,6 +54,7 @@ from .history import HistorySeed, entry_to_dict
54
54
  from .llm import LLMMessage, LLMUsage
55
55
  from .loop import CoreLoop
56
56
  from .registry import ChildRegistry, SpawnedChild, default_record_id, registry_status
57
+ from .tree import AgentTree, TreeMember
57
58
 
58
59
  logger = logging.getLogger(__name__)
59
60
 
@@ -101,6 +102,12 @@ class OrchestrationConfig:
101
102
  close_tool: str = "agent_close"
102
103
  list_tool: str = "agent_list"
103
104
  interrupt_tool: str = "agent_interrupt"
105
+ #: R8 shared task board. Advertised only when a board is wired; the names
106
+ #: are configurable so a host can avoid colliding with its own tools.
107
+ task_create_tool: str = "team_task_create"
108
+ task_claim_tool: str = "team_task_claim"
109
+ task_complete_tool: str = "team_task_complete"
110
+ task_list_tool: str = "team_task_list"
104
111
 
105
112
  @property
106
113
  def tool_names(self) -> frozenset[str]:
@@ -115,6 +122,19 @@ class OrchestrationConfig:
115
122
  }
116
123
  )
117
124
 
125
+ @property
126
+ def team_tool_names(self) -> frozenset[str]:
127
+ """The four board tools — separate so a nested child narrows its
128
+ orchestration family without losing the shared plan."""
129
+ return frozenset(
130
+ {
131
+ self.task_create_tool,
132
+ self.task_claim_tool,
133
+ self.task_complete_tool,
134
+ self.task_list_tool,
135
+ }
136
+ )
137
+
118
138
 
119
139
  @dataclass(frozen=True, slots=True)
120
140
  class ChildOutcome:
@@ -226,6 +246,7 @@ class AgentPool:
226
246
  parent_record_id: str | None = None,
227
247
  budget: TreeBudget | None = None,
228
248
  history_store: Any | None = None,
249
+ tree: AgentTree | None = None,
229
250
  ) -> None:
230
251
  self._config = config
231
252
  self._depth = depth
@@ -264,6 +285,22 @@ class AgentPool:
264
285
  #: into the child's own record; ``None`` keeps the legacy behaviour
265
286
  #: (the seed is just the child's opening messages).
266
287
  self._history_store = history_store
288
+ #: Tree-wide addressing, shared by every pool in one spawn tree. Each
289
+ #: pool registers the children it owns, so a peer message reaches an
290
+ #: agent through the owner that can actually steer or resume it.
291
+ #: ``None`` keeps the pre-R8 behaviour: only this pool's own children
292
+ #: are addressable.
293
+ self._tree = tree
294
+
295
+ @property
296
+ def tree(self) -> AgentTree | None:
297
+ """The tree this pool's children register into, or ``None``.
298
+
299
+ Exposed so another surface that shares this pool (the delegation seam)
300
+ can give its own children the same peer-addressing tools: membership
301
+ is a property of the tree, not of which executor built the child.
302
+ """
303
+ return self._tree
267
304
 
268
305
  def _emit(self, kind: str, data: dict[str, Any]) -> None:
269
306
  if self._event_sink is not None:
@@ -378,6 +415,7 @@ class AgentPool:
378
415
  if system_prompt:
379
416
  child_seed.insert(0, LLMMessage.text_of("system", system_prompt))
380
417
  handle.task = asyncio.ensure_future(self._run_child(handle, child_seed))
418
+ self._join_tree(child_id, profile=profile)
381
419
  loop_record_id = loop.record_id if loop is not None else None
382
420
  self._emit(
383
421
  "child_spawned",
@@ -448,6 +486,7 @@ class AgentPool:
448
486
  )
449
487
  self._watch_child(handle, loop)
450
488
  handle.task = asyncio.ensure_future(self._run_child(handle, list(seed)))
489
+ self._join_tree(child_id, profile=profile)
451
490
  self._emit(
452
491
  "child_resumed",
453
492
  {
@@ -459,8 +498,7 @@ class AgentPool:
459
498
  )
460
499
  return handle
461
500
 
462
- def _write_seed_entry(
463
- self,
501
+ def _write_seed_entry( self,
464
502
  loop: CoreLoop,
465
503
  seed: list[LLMMessage],
466
504
  provenance: SeedProvenance,
@@ -493,6 +531,57 @@ class AgentPool:
493
531
  except Exception as exc: # noqa: BLE001 — bookkeeping never fails a spawn
494
532
  logger.warning("fork seed provenance write failed: %s", exc)
495
533
 
534
+ def _join_tree(self, child_id: str, *, profile: str | None) -> None:
535
+ """Make this child addressable by peers while it exists.
536
+
537
+ The pool owns the loop, so it is also the thing that can deliver a
538
+ message to it — registering here is what lets a sibling reach this
539
+ child without knowing which executor built it.
540
+ """
541
+ if self._tree is None:
542
+ return
543
+ self._tree.register(
544
+ TreeMember(
545
+ lineage=child_id,
546
+ depth=self._depth + 1,
547
+ parent=self._lineage or None,
548
+ profile=profile,
549
+ ),
550
+ lambda message, cid=child_id: self._deliver_to_peer(cid, message),
551
+ )
552
+
553
+ def _leave_tree(self, child_id: str) -> None:
554
+ """Stop resolving this child's address (closed or wound down)."""
555
+ if self._tree is not None:
556
+ self._tree.unregister(child_id)
557
+
558
+ async def _deliver_to_peer(self, child_id: str, message: str) -> str:
559
+ """Deliver a peer's message to one child, asynchronously.
560
+
561
+ ``AgentPool.send`` is synchronous because a tool call runs inside the
562
+ loop and must answer in one step; a peer message instead arrives from
563
+ another agent's tool call, and resuming that child means awaiting its
564
+ new run's first step. So this routes through the same vocabulary
565
+ ``send`` returns (``steered`` / ``resumed`` / ``error:*``) while
566
+ allowing the resume to await.
567
+ """
568
+ verdict = self.send(child_id, message)
569
+ if verdict != "resumed":
570
+ return verdict
571
+ handle = self._children.get(child_id)
572
+ if handle is None or handle.task is None:
573
+ return "resumed"
574
+ # The resumed child is running; wait for it to settle so the sender
575
+ # learns the outcome rather than a bare "accepted". Shielded: a
576
+ # timeout on the sender's side must not cancel the work.
577
+ try:
578
+ await asyncio.shield(handle.task)
579
+ except asyncio.CancelledError:
580
+ raise
581
+ except Exception: # noqa: BLE001 — the child's own outcome carries it
582
+ pass
583
+ return verdict
584
+
496
585
  def _register_child(
497
586
  self,
498
587
  child_id: str,
@@ -1039,6 +1128,10 @@ class AgentPool:
1039
1128
  if handle is None:
1040
1129
  return False
1041
1130
  handle.closed = True
1131
+ # Closing is the caller's "never again": the address stops resolving
1132
+ # so a peer's message reports an unknown member instead of resuming an
1133
+ # agent the owner already gave up on.
1134
+ self._leave_tree(child_id)
1042
1135
  if handle.outcome is not None:
1043
1136
  return True
1044
1137
  self._cancel_handle(handle)
@@ -1071,6 +1164,7 @@ class AgentPool:
1071
1164
  live = [h for h in self._children.values() if h.outcome is None]
1072
1165
  for handle in self._children.values():
1073
1166
  handle.closed = True
1167
+ self._leave_tree(handle.child_id)
1074
1168
  for handle in live:
1075
1169
  self._cancel_handle(handle)
1076
1170
  for handle in live:
@@ -69,6 +69,7 @@ from .history import completed_turn_prefix
69
69
  from .llm import LLMMessage, LLMProvider
70
70
  from .loop import CoreLoop, LoopConfig, LoopContext, LoopHooks, ToolExecutor
71
71
  from .engines import AcpEngineSpec, AcpPermissionPolicy, AcpToolBridge
72
+ from .peer import PeerToolsExecutor, peer_schemas
72
73
  from .pool import (
73
74
  AgentPool,
74
75
  LoopFactory,
@@ -441,8 +442,7 @@ class SubagentExecutor:
441
442
  #: which row is waiting on the user.
442
443
  self._event_sink = event_sink
443
444
  #: Where an external child's file and terminal requests land. ``None``
444
- #: means this host grants none (a bridge with no capabilities refuses
445
- #: everything), which is the safe default for a spawned process.
445
+ #: means this host grants none (a bridge with no capabilities refuses #: everything), which is the safe default for a spawned process.
446
446
  self._acp_tool_bridge = acp_tool_bridge
447
447
  #: The parent's model-visible transcript, read at delegation time.
448
448
  #: ``None`` means this host cannot fork, and the tool schema omits the
@@ -655,6 +655,30 @@ class SubagentExecutor:
655
655
  and (tool_filter is None or _schema_name(schema) in tool_filter)
656
656
  ]
657
657
  schemas.append(parent_input_tool_descriptor())
658
+ base: ToolExecutor = _ParentInputExecutor(
659
+ self._child_executor(config),
660
+ lambda payload: self._parent_input_requests.__setitem__(
661
+ child_id, payload
662
+ ),
663
+ )
664
+ # A delegated child is a member of the turn's job: it registers in
665
+ # the same tree the orchestration children do (the pool's
666
+ # ``_join_tree`` does that), so it can hand a finding to a sibling
667
+ # or answer the parent instead of routing everything back through
668
+ # the tool result. Read from the pool rather than a constructor
669
+ # argument because ``attach_pool`` adopts the shared pool after
670
+ # construction — the tree is whatever that pool has.
671
+ tree = self._pool.tree
672
+ if tree is not None:
673
+ # Same names as the orchestration family: a delegated child
674
+ # and an orchestration child are one kind of peer.
675
+ peer_config = OrchestrationConfig(
676
+ max_parallel=self._config.max_parallel
677
+ )
678
+ base = PeerToolsExecutor(
679
+ base, tree, peer_config, lineage=child_id
680
+ )
681
+ schemas = [*schemas, *peer_schemas(peer_config)]
658
682
  # A child writes its own durable record when the host gave us a
659
683
  # store and a prefix, so the delegation's process can be read
660
684
  # back afterwards (`<parent record>:child:<lineage id>`). The
@@ -668,12 +692,7 @@ class SubagentExecutor:
668
692
  return (
669
693
  CoreLoop(
670
694
  provider,
671
- _ParentInputExecutor(
672
- self._child_executor(config),
673
- lambda payload: self._parent_input_requests.__setitem__(
674
- child_id, payload
675
- ),
676
- ),
695
+ base,
677
696
  LoopConfig(
678
697
  max_rounds=config.max_rounds,
679
698
  max_tool_errors=config.max_tool_errors,
@@ -0,0 +1,229 @@
1
+ """The shared task board: one plan the whole spawn tree works from.
2
+
3
+ Without it, splitting a job means the parent hands out self-contained tasks
4
+ and then **remembers** who is doing what. That memory is the parent's context,
5
+ which is exactly the thing that gets long when a job is big enough to need four
6
+ agents. A board moves the plan out of any one context: the parent writes down
7
+ the work, workers claim items, and anyone can see what is still unclaimed.
8
+
9
+ Two decisions worth naming:
10
+
11
+ - **Claiming is atomic.** Two workers that both see "auth refactor: todo" must
12
+ not both start it; ``claim`` either binds the caller or fails. That is the
13
+ whole point of a board over a list in a prompt.
14
+ - **The board does not block.** ``claim`` returning "nothing free" is
15
+ information, not a reason to wait. A worker that waits on the board would
16
+ idle a slot the parent is budgeting for, so the caller decides what to do
17
+ next — and ``team_task_list`` shows what exists.
18
+
19
+ Ownership is the caller's lineage id (``0.2``), so the board also answers "who
20
+ is doing this" without a second registry.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from dataclasses import dataclass, field, replace
27
+ from datetime import datetime, timezone
28
+
29
+ __all__ = ["TaskItem", "TeamTaskBoard"]
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ #: Statuses in the order work moves through them. ``todo`` is unclaimed; the
34
+ #: rest exist so a reader can tell "nobody took it" from "someone has it" from
35
+ #: "it is done" without reading every entry.
36
+ TASK_STATUSES = ("todo", "claimed", "done")
37
+
38
+
39
+ def _now_iso() -> str:
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class TaskItem:
45
+ """One unit of work on the board.
46
+
47
+ ``owner`` is the claimant's lineage id (``None`` while ``todo``), and
48
+ ``depends_on`` names other task ids — the shape DSH's team board uses, so a
49
+ worker can tell "I can start" from "I am waiting on someone".
50
+ """
51
+
52
+ id: str
53
+ title: str
54
+ status: str
55
+ owner: str | None = None
56
+ detail: str | None = None
57
+ depends_on: tuple[str, ...] = ()
58
+ created_by: str | None = None
59
+ created_at: str = ""
60
+ updated_at: str = ""
61
+
62
+ @property
63
+ def blocked(self) -> bool:
64
+ """True while a dependency is still unfinished.
65
+
66
+ Computed by the board (which can see the others), not here: an item
67
+ alone cannot know whether its dependencies are done.
68
+ """
69
+ return bool(self.depends_on)
70
+
71
+ def to_wire(self) -> dict[str, object]:
72
+ """Camel-case payload for a tool result."""
73
+ return {
74
+ "id": self.id,
75
+ "title": self.title,
76
+ "status": self.status,
77
+ **({"owner": self.owner} if self.owner else {}),
78
+ **({"detail": self.detail} if self.detail else {}),
79
+ **({"dependsOn": list(self.depends_on)} if self.depends_on else {}),
80
+ **({"createdBy": self.created_by} if self.created_by else {}),
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class TeamTaskBoard:
86
+ """One board per spawn tree, shared by every member.
87
+
88
+ The board is plain in-process state: it belongs to a turn tree, and a
89
+ tree does not outlive the turn that started it. Nothing here is durable on
90
+ purpose — a finished turn's plan has no reader, and persisting it would
91
+ create a second place where "what was this job" lives.
92
+ """
93
+
94
+ _items: dict[str, TaskItem] = field(default_factory=dict)
95
+ _seq: int = 0
96
+
97
+ def create(
98
+ self,
99
+ *,
100
+ title: str,
101
+ detail: str | None = None,
102
+ depends_on: tuple[str, ...] = (),
103
+ created_by: str | None = None,
104
+ ) -> TaskItem:
105
+ """Add one unclaimed item and return it.
106
+
107
+ Dependencies are validated here rather than at claim time: an item
108
+ that names a task nobody created would never become claimable, and the
109
+ author is the one who can fix the typo.
110
+ """
111
+ missing = [dep for dep in depends_on if dep not in self._items]
112
+ if missing:
113
+ raise KeyError(
114
+ f"unknown dependenc{'y' if len(missing) == 1 else 'ies'}: "
115
+ f"{', '.join(missing)}"
116
+ )
117
+ self._seq += 1
118
+ now = _now_iso()
119
+ item = TaskItem(
120
+ id=f"t{self._seq}",
121
+ title=title,
122
+ status="todo",
123
+ detail=detail,
124
+ depends_on=tuple(depends_on),
125
+ created_by=created_by,
126
+ created_at=now,
127
+ updated_at=now,
128
+ )
129
+ self._items[item.id] = item
130
+ return item
131
+
132
+ def get(self, task_id: str) -> TaskItem | None:
133
+ """One item, or ``None`` when the id is unknown."""
134
+ return self._items.get(task_id)
135
+
136
+ def items(self) -> list[TaskItem]:
137
+ """Every item, in creation order."""
138
+ return list(self._items.values())
139
+
140
+ def ready(self) -> list[TaskItem]:
141
+ """Unclaimed items whose dependencies are all done.
142
+
143
+ This is what a worker without a specific id should claim: the answer
144
+ "nothing ready, three blocked" is actionable in a way that a flat
145
+ unclaimed list is not.
146
+ """
147
+ done = {item.id for item in self._items.values() if item.status == "done"}
148
+ return [
149
+ item
150
+ for item in self._items.values()
151
+ if item.status == "todo" and all(dep in done for dep in item.depends_on)
152
+ ]
153
+
154
+ def claim(self, task_id: str, *, owner: str) -> TaskItem:
155
+ """Bind one ready item to ``owner``.
156
+
157
+ Raises:
158
+ KeyError: the id is unknown.
159
+ ValueError: the item is not claimable (already claimed, done, or
160
+ still blocked) — the message says which, because "cannot
161
+ claim" alone tells the caller nothing about whether to wait,
162
+ pick another item, or fix a dependency.
163
+
164
+ Claiming is the one operation that must not race: two workers reading
165
+ the same ``todo`` and both proceeding would duplicate the work, so the
166
+ check and the bind happen in one step here and nowhere else.
167
+ """
168
+ item = self._items.get(task_id)
169
+ if item is None:
170
+ raise KeyError(f"unknown task {task_id!r}")
171
+ if item.status == "done":
172
+ raise ValueError(f"task {task_id} is already done")
173
+ if item.status == "claimed":
174
+ raise ValueError(
175
+ f"task {task_id} is already claimed by {item.owner}"
176
+ )
177
+ done = {other.id for other in self._items.values() if other.status == "done"}
178
+ blocked = [dep for dep in item.depends_on if dep not in done]
179
+ if blocked:
180
+ raise ValueError(
181
+ f"task {task_id} is blocked by {', '.join(blocked)}"
182
+ )
183
+ claimed = replace(
184
+ item, status="claimed", owner=owner, updated_at=_now_iso()
185
+ )
186
+ self._items[task_id] = claimed
187
+ return claimed
188
+
189
+ def complete(
190
+ self, task_id: str, *, owner: str | None = None, note: str | None = None
191
+ ) -> TaskItem:
192
+ """Mark one claimed item done.
193
+
194
+ ``owner`` is checked when given: a worker finishing another worker's
195
+ item is far more often a mistake (wrong id) than intent, and the
196
+ message names the real owner so the caller can correct itself.
197
+
198
+ Raises:
199
+ KeyError: the id is unknown.
200
+ ValueError: the item is not claimed, or claimed by someone else.
201
+ """
202
+ item = self._items.get(task_id)
203
+ if item is None:
204
+ raise KeyError(f"unknown task {task_id!r}")
205
+ if item.status == "done":
206
+ return item
207
+ if item.status != "claimed":
208
+ raise ValueError(
209
+ f"task {task_id} is {item.status}, not claimed; claim it first"
210
+ )
211
+ if owner is not None and item.owner is not None and item.owner != owner:
212
+ raise ValueError(
213
+ f"task {task_id} is claimed by {item.owner}, not {owner}"
214
+ )
215
+ detail = item.detail if note is None else (
216
+ f"{item.detail}\n{note}" if item.detail else note
217
+ )
218
+ done = replace(
219
+ item, status="done", detail=detail, updated_at=_now_iso()
220
+ )
221
+ self._items[task_id] = done
222
+ return done
223
+
224
+ def summary(self) -> dict[str, int]:
225
+ """Counts by status, for a one-line board overview."""
226
+ counts = {status: 0 for status in TASK_STATUSES}
227
+ for item in self._items.values():
228
+ counts[item.status] = counts.get(item.status, 0) + 1
229
+ return counts