piloop 0.1.0__py3-none-any.whl

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 (110) hide show
  1. piloop/__init__.py +0 -0
  2. piloop/__main__.py +3 -0
  3. piloop/agents/__init__.py +1 -0
  4. piloop/agents/capture.py +118 -0
  5. piloop/agents/launcher.py +443 -0
  6. piloop/agents/prompts/research.md +229 -0
  7. piloop/agents/prompts/research_scout.md +129 -0
  8. piloop/agents/prompts/sql_procedures.md +295 -0
  9. piloop/agents/prompts/watchdog.md +30 -0
  10. piloop/agents/prompts/watchdog_incident.md +23 -0
  11. piloop/agents/prompts/watchdog_progress.md +25 -0
  12. piloop/agents/prompts/worker.md +94 -0
  13. piloop/agents/refill.py +33 -0
  14. piloop/agents/roles.py +214 -0
  15. piloop/agents/scout.py +159 -0
  16. piloop/campaign/__init__.py +1 -0
  17. piloop/campaign/ideas.py +347 -0
  18. piloop/campaign/inbox.py +235 -0
  19. piloop/campaign/project.py +242 -0
  20. piloop/campaign/spend.py +143 -0
  21. piloop/campaign/stop.py +147 -0
  22. piloop/campaign/templates/enwik8/.entire/.gitignore +5 -0
  23. piloop/campaign/templates/enwik8/.entire/settings.json +1 -0
  24. piloop/campaign/templates/enwik8/brief.md +32 -0
  25. piloop/campaign/templates/enwik8/compressor.py +13 -0
  26. piloop/campaign/templates/enwik8/evaluate.py +69 -0
  27. piloop/campaign/templates/enwik8/piloop.toml +31 -0
  28. piloop/campaign/templates/enwik8/pyproject.toml +5 -0
  29. piloop/campaign/templates/project/.entire/.gitignore +5 -0
  30. piloop/campaign/templates/project/.entire/settings.json +1 -0
  31. piloop/campaign/templates/project/brief.md +47 -0
  32. piloop/campaign/templates/project/evaluate.py +25 -0
  33. piloop/campaign/templates/project/piloop.toml +94 -0
  34. piloop/campaign/templates/titanic-tracks/.entire/.gitignore +5 -0
  35. piloop/campaign/templates/titanic-tracks/.entire/settings.json +1 -0
  36. piloop/campaign/templates/titanic-tracks/brief.md +33 -0
  37. piloop/campaign/templates/titanic-tracks/briefs/accuracy.md +4 -0
  38. piloop/campaign/templates/titanic-tracks/briefs/cost.md +5 -0
  39. piloop/campaign/templates/titanic-tracks/evaluate.py +89 -0
  40. piloop/campaign/templates/titanic-tracks/piloop.toml +41 -0
  41. piloop/campaign/templates/titanic-tracks/pyproject.toml +5 -0
  42. piloop/campaign/templates/titanic-tracks/solve.py +31 -0
  43. piloop/cli/__init__.py +1 -0
  44. piloop/cli/agent_skills/piloop-operate/SKILL.md +375 -0
  45. piloop/cli/agent_skills/piloop-review/SKILL.md +275 -0
  46. piloop/cli/agent_skills/piloop-setup/SKILL.md +222 -0
  47. piloop/cli/doctor.py +274 -0
  48. piloop/cli/main.py +995 -0
  49. piloop/cli/skills.py +85 -0
  50. piloop/cli/upgrade.py +31 -0
  51. piloop/cli/version.py +22 -0
  52. piloop/config.py +588 -0
  53. piloop/daemon/__init__.py +1 -0
  54. piloop/daemon/main.py +121 -0
  55. piloop/daemon/runner.py +732 -0
  56. piloop/daemon/scheduler.py +1300 -0
  57. piloop/daemon/serve.py +259 -0
  58. piloop/db/__init__.py +1 -0
  59. piloop/db/actor_triggers.sql +113 -0
  60. piloop/db/events.py +23 -0
  61. piloop/db/migrations/env.py +31 -0
  62. piloop/db/migrations/script.py.mako +24 -0
  63. piloop/db/migrations/versions/0001_initial.py +474 -0
  64. piloop/db/models.py +683 -0
  65. piloop/db/persistent_triggers.sql +70 -0
  66. piloop/db/session.py +140 -0
  67. piloop/db/sql.py +256 -0
  68. piloop/db/triggers/__init__.py +94 -0
  69. piloop/db/triggers/changelog.py +124 -0
  70. piloop/db/triggers/ddl.py +51 -0
  71. piloop/db/triggers/fts.py +66 -0
  72. piloop/db/triggers/links.py +22 -0
  73. piloop/db/triggers/provenance.py +51 -0
  74. piloop/deprecated.py +47 -0
  75. piloop/git.py +180 -0
  76. piloop/harnesses.py +234 -0
  77. piloop/mcp/__init__.py +173 -0
  78. piloop/mcp/claude.py +29 -0
  79. piloop/mcp/codex.py +110 -0
  80. piloop/mcp/pi.py +58 -0
  81. piloop/otel.py +109 -0
  82. piloop/proc.py +87 -0
  83. piloop/reports/__init__.py +1 -0
  84. piloop/reports/history.py +252 -0
  85. piloop/reports/overview.py +920 -0
  86. piloop/reports/pack.py +333 -0
  87. piloop/runtime/__init__.py +124 -0
  88. piloop/runtime/factory.py +48 -0
  89. piloop/runtime/herdr.py +775 -0
  90. piloop/runtime/subprocess.py +142 -0
  91. piloop/scoring/__init__.py +1 -0
  92. piloop/scoring/evaluate.py +151 -0
  93. piloop/scoring/ledger.py +496 -0
  94. piloop/scoring/verdict.py +160 -0
  95. piloop/settings.py +116 -0
  96. piloop/text.py +23 -0
  97. piloop/usage/__init__.py +180 -0
  98. piloop/usage/jsonl.py +276 -0
  99. piloop/usage/tallies/__init__.py +10 -0
  100. piloop/usage/tallies/claude.py +45 -0
  101. piloop/usage/tallies/codex.py +52 -0
  102. piloop/usage/tallies/pi.py +38 -0
  103. piloop/watch/__init__.py +1 -0
  104. piloop/watch/app.py +545 -0
  105. piloop/watch/panels.py +376 -0
  106. piloop-0.1.0.dist-info/METADATA +147 -0
  107. piloop-0.1.0.dist-info/RECORD +110 -0
  108. piloop-0.1.0.dist-info/WHEEL +4 -0
  109. piloop-0.1.0.dist-info/entry_points.txt +2 -0
  110. piloop-0.1.0.dist-info/licenses/LICENSE +21 -0
piloop/__init__.py ADDED
File without changes
piloop/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from piloop.cli.main import main
2
+
3
+ main()
@@ -0,0 +1 @@
1
+ """The agents piloop launches: the roles and their briefs, the one lifecycle, session capture and token usage."""
@@ -0,0 +1,118 @@
1
+ """Session capture through Entire. When an agent ends, attach the harness's session by the id the launcher learned.
2
+
3
+ piloop calls Entire once per agent session. ``entire session attach <id> --agent <name>`` reads the harness's session
4
+ file, redacts it and writes the checkpoint ref ``refs/entire/checkpoints/<id[-2:]>/<id>`` into the repo. piloop keeps
5
+ the id on the ``workers`` row. No hooks, no config in the tree, nothing per commit.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from piloop.db.models import Worker
15
+ from piloop.harnesses import Harness
16
+ from piloop.proc import STDERR_IN_ERROR, child_env
17
+ from piloop.settings import settings
18
+
19
+ ENTIRE = 'entire'
20
+ ENTIRE_DIR = '.entire'
21
+ """Entire's per-repo settings and the ``.gitignore`` for its scratch files.
22
+
23
+ ``settings.json`` stores checkpoints as git refs, never pushes sessions and turns telemetry off. ``piloop init``
24
+ commits the directory. Entire reads it from the tree of the worktree it runs in. A worktree of an older tree that does
25
+ not have it gets a link to the project's (``piloop.campaign.project.worktree_links``).
26
+ """
27
+ ENTIRE_INSTALL = 'curl -fsSL https://entire.io/install.sh | bash'
28
+ MIN_ENTIRE = '0.10.6'
29
+ """The oldest Entire CLI that piloop attaches sessions with. An older one is not used."""
30
+ VERSION = re.compile(r'\d+(?:\.\d+)+')
31
+ """Finds the version in ``Entire CLI 0.10.6``, the first line of ``entire version``."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Attached:
36
+ """What ``entire session info <sid> --json`` reports about a session that was just attached."""
37
+
38
+ checkpoint_id: str
39
+ agent: str
40
+ """Entire's display name for the harness (``Codex``)."""
41
+ tokens: int | None
42
+ """The session's ``tokens.total``."""
43
+
44
+
45
+ class AttachError(RuntimeError):
46
+ """An ``entire`` call failed or gave no checkpoint. The message says why."""
47
+
48
+
49
+ def attachable(worker: Worker, harness: Harness | None) -> tuple[str, str] | None:
50
+ """Return ``(session_id, entire agent)`` of a worker whose session Entire can attach, else None."""
51
+ agent = harness.entire_agent if harness is not None else None
52
+ return (worker.session_id, agent) if agent and worker.session_id else None
53
+
54
+
55
+ def attach_session(session_id: str, agent: str, cwd: Path) -> Attached:
56
+ """Attach an ended harness session to Entire from ``cwd``, then read back the checkpoint it made.
57
+
58
+ ``cwd`` is the repo that gets the checkpoint ref, and where Entire looks for the session file first, because pi
59
+ and Claude Code key their stores by the agent's cwd. It is the agent's worktree while that exists, else the project
60
+ checkout. The call is non-interactive, so Entire prints the trailer and amends nothing. The checkpoint id comes
61
+ from ``session info --json`` (``last_checkpoint_id``), not from the printed text.
62
+
63
+ Raises:
64
+ AttachError: ``entire`` is not on PATH, exited non-zero, exceeded ``entire_attach_timeout_seconds`` or
65
+ reports no checkpoint for the session.
66
+ """
67
+ _entire('session', 'attach', session_id, '--agent', agent, cwd=cwd)
68
+ info = json.loads(_entire('session', 'info', session_id, '--json', cwd=cwd))
69
+ checkpoint = info.get('last_checkpoint_id')
70
+ if not checkpoint:
71
+ raise AttachError(f'{ENTIRE} session info reports no checkpoint for session {session_id}')
72
+ total = (info.get('tokens') or {}).get('total')
73
+ return Attached(str(checkpoint), str(info.get('agent', agent)), None if total is None else int(total))
74
+
75
+
76
+ def entire_version() -> str:
77
+ """Return the version of the installed Entire CLI. ``entire version`` starts with ``Entire CLI 0.10.6``.
78
+
79
+ Raises:
80
+ AttachError: ``entire`` is not on PATH (the message says how to install it), failed or printed no version.
81
+ """
82
+ first = _entire('version', cwd=Path.cwd()).partition('\n')[0]
83
+ found = VERSION.search(first)
84
+ if found is None:
85
+ raise AttachError(f'{ENTIRE} version printed no version: {first[:STDERR_IN_ERROR]}')
86
+ return found.group()
87
+
88
+
89
+ def usable(version: str) -> bool:
90
+ """Return whether Entire ``version`` is at least ``MIN_ENTIRE``."""
91
+ return _parts(version) >= _parts(MIN_ENTIRE)
92
+
93
+
94
+ def _parts(version: str) -> tuple[int, ...]:
95
+ return tuple(int(p) for p in version.split('.'))
96
+
97
+
98
+ def _entire(*args: str, cwd: Path) -> str:
99
+ """Run ``entire <args>`` in ``cwd`` within ``entire_attach_timeout_seconds`` and return its stdout."""
100
+ timeout = settings.entire_attach_timeout_seconds
101
+ what = f'{ENTIRE} {" ".join(args[:2])}'
102
+ try:
103
+ done = subprocess.run(
104
+ [ENTIRE, *args],
105
+ cwd=cwd,
106
+ stdin=subprocess.DEVNULL,
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=timeout,
110
+ env=child_env(),
111
+ )
112
+ except FileNotFoundError as e:
113
+ raise AttachError(f'{ENTIRE} is not on PATH; install it: {ENTIRE_INSTALL}') from e
114
+ except subprocess.TimeoutExpired as e:
115
+ raise AttachError(f'{what} timed out after {timeout:g}s') from e
116
+ if done.returncode:
117
+ raise AttachError(f'{what} exit {done.returncode}: {done.stderr.strip()[:STDERR_IN_ERROR]}')
118
+ return done.stdout
@@ -0,0 +1,443 @@
1
+ """One lifecycle for every agent role (https://piloop.autoresearch.inc/concepts/roles): brief, start, wait, collect, close the ``workers`` row.
2
+
3
+ Roles differ in data (``piloop.agents.roles``) and in the content of their brief. ``Launcher.launch`` is the only code
4
+ that starts, waits for and closes an agent process. It never raises: a failure goes on the worker row and its span.
5
+ """
6
+
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, replace
11
+ from pathlib import Path
12
+ from uuid import uuid4
13
+
14
+ from sqlalchemy import func, select
15
+ from sqlalchemy.orm import Session
16
+
17
+ from piloop import git
18
+ from piloop.agents.capture import ENTIRE_INSTALL, MIN_ENTIRE, AttachError, attach_session, attachable, usable
19
+ from piloop.agents.refill import usage_of
20
+ from piloop.agents.roles import role_of
21
+ from piloop.config import BRIEF_FILE, RuntimeKind, config_of, harness_of
22
+ from piloop.db.events import emit
23
+ from piloop.db.models import Comment, Project, Run, TargetKind, UsageJson, Worker, WorkerState, utcnow
24
+ from piloop.db.session import Database
25
+ from piloop.harnesses import fill, takes_session_id
26
+ from piloop.mcp import RENDERERS, Launch, Rendered, expand, forwarded_env
27
+ from piloop.otel import Span, Tracer
28
+ from piloop.proc import child_env
29
+ from piloop.runtime import Handle, Outcome, Runtime, StartFailed, WorkerSpec, WorkerStatus
30
+ from piloop.runtime.factory import RuntimeFactory, handle_for, make_runtime, runtime_for
31
+ from piloop.text import cell
32
+ from piloop.usage import UsageError
33
+
34
+ TRANSCRIPT = 'transcript.txt'
35
+ TRANSCRIPT_LINES = 2000
36
+ START_SCREEN_LINES = 12
37
+ """How many non-blank lines from the end of a failed start's screen go into the row's error."""
38
+ STOPPED_ERROR = 'daemon stopped'
39
+ STARTUP_SECONDS = 60
40
+ """An agent that ends sooner than this without spending a token did not start to work: a dialog, a crash."""
41
+ STARTUP_ANOMALY = 'anomaly.startup'
42
+ """The event on a worker that did not start to work (``Outcome.startup_failed``): an environment fault, not bad work."""
43
+ CLEARED_ENV = ('CLAUDE_CODE_CHILD_SESSION',)
44
+ """Variables to clear in an agent's environment.
45
+
46
+ A Claude Code session sets ``CLAUDE_CODE_CHILD_SESSION`` for every process it runs, so a daemon or Herdr server started
47
+ from one passes it on. A Claude Code agent that inherits it saves no transcript, so it has no token usage and no
48
+ Entire checkpoint. The value is set to empty, which Claude Code reads as unset, because a Herdr pane can only override
49
+ what its server inherited.
50
+ """
51
+ PROMPT = 'Your brief is in {path}. Read all of it, in parts if a read comes back cut short, then follow it.'
52
+ """An agent's first prompt, on its argv or Herdr's (``agent prompt``).
53
+
54
+ It names the brief's file instead of holding the brief. The prompt is one argument, and Linux limits one argument to
55
+ 128 KiB (``MAX_ARG_STRLEN``). A research brief with a long history is longer than that.
56
+ """
57
+ TAB_TITLE_CHARS = 30
58
+ """How many characters of the idea's title go in a worker's tab label: enough to tell tabs apart in Herdr."""
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Prepared:
63
+ """A worker row marked ``starting``: what the runtime needs to start it, and how long it may run."""
64
+
65
+ spec: WorkerSpec
66
+ runtime: Runtime
67
+ timeout: float
68
+ captures_session: bool
69
+ """Whether the session id is needed when the agent ends, for the Entire attach and the token usage."""
70
+ session_id: str | None
71
+ """Created here when the argv takes ``{session_id}``. None when the runtime reports it after the start."""
72
+
73
+
74
+ class Launcher:
75
+ """Turns a ``workers`` row into a process, and the ended process into a closed row, on the calling thread."""
76
+
77
+ def __init__(
78
+ self, db: Database, tracer: Tracer, home: Path, runtime_factory: RuntimeFactory = make_runtime
79
+ ) -> None:
80
+ self.db = db
81
+ self.tracer = tracer
82
+ self.home = home
83
+ self.runtime_factory = runtime_factory
84
+ self.cancelled = threading.Event()
85
+ """Set when the daemon stops. Agents not started yet are skipped, and the daemon closes the rows of the rest."""
86
+ self.entire_version: str | None = None
87
+ """What ``entire version`` printed when the daemon started.
88
+
89
+ None before that, or without ``entire``. The attach is still tried then, and the event says why it failed.
90
+ Below ``MIN_ENTIRE``, no session is attached.
91
+ """
92
+ self.lock = threading.Lock()
93
+ """Held from the ``cancelled`` check through ``runtime.start`` to the stored handle, and around row writes.
94
+
95
+ ``cancel`` sets the flag under this lock. So after ``cancel`` returns, every agent either has its handle stored
96
+ (and the daemon kills it) or never starts, and no thread reopens a row that the daemon closed. ``RunExecutor``
97
+ shares the lock for the run row and the evaluate process.
98
+ """
99
+ self.handover = threading.Event()
100
+ """Set before ``cancelled`` by an in-place restart (``RunExecutor.hand_over``).
101
+
102
+ The agents keep running, and the next daemon process re-attaches to them (``reattach``). So a row that
103
+ ``cancelled`` leaves open is handed over, not stopped.
104
+ """
105
+ self.watching: set[int] = set()
106
+ """Workers whose agent this process started or re-attached to and did not close yet.
107
+
108
+ A hand-over leaves these to the next daemon, and waits for every other thread.
109
+ """
110
+
111
+ def cancel(self) -> None:
112
+ """Start no more agents. The daemon writes the rows of the agents it kills."""
113
+ with self.lock:
114
+ self.cancelled.set()
115
+
116
+ def launch(self, worker_id: int, *, brief: str, cwd: Path, log_dir: Path) -> Outcome:
117
+ """Run worker row ``worker_id`` to the end and close it.
118
+
119
+ The steps are: pick the harness and model, start, wait, save the transcript, read the usage, attach the
120
+ session, close the row and emit the events. The caller owns ``cwd`` (a worktree) and removes it after this
121
+ returns, unless the agent was handed over (``Outcome.handed_over``). The usage is read and the session is
122
+ attached before that, from inside ``cwd``.
123
+ """
124
+ return self._run(worker_id, cwd, log_dir, lambda span: self._launch(worker_id, span, brief, cwd, log_dir))
125
+
126
+ def reattach(self, worker_id: int, *, cwd: Path, log_dir: Path) -> Outcome:
127
+ """Like ``launch``, for an agent that an earlier daemon process started: watch it to its end and close it.
128
+
129
+ The agent can still run, or it can have ended while no daemon watched. Its role's timeout counts from its start.
130
+ """
131
+ return self._run(worker_id, cwd, log_dir, lambda span: self._reattach(worker_id, span, log_dir))
132
+
133
+ def _run(self, worker_id: int, cwd: Path, log_dir: Path, agent: Callable[[Span], Outcome]) -> Outcome:
134
+ """Run the agent phase (``agent``), then read the usage, check the startup, attach the session and close."""
135
+ started = time.monotonic()
136
+ with self.tracer.span('piloop.agent', worker=worker_id) as span:
137
+ try:
138
+ try:
139
+ outcome = agent(span)
140
+ killed = outcome.status is WorkerStatus.TIMED_OUT or outcome.startup_failed
141
+ except Exception as e:
142
+ span.error(e)
143
+ outcome = Outcome(WorkerStatus.EXITED, time.monotonic() - started, error=f'{type(e).__name__}: {e}')
144
+ killed = True
145
+ cancelled = self.cancelled.is_set()
146
+ usage = None if cancelled else self._usage(worker_id, cwd, log_dir, span)
147
+ if not cancelled and not outcome.startup_failed and self._never_worked(worker_id, outcome, usage, cwd):
148
+ outcome = replace(outcome, startup_failed=True)
149
+ checkpoint = None if cancelled else self._attach(worker_id, cwd, span)
150
+ if self._close(worker_id, outcome, killed, span, checkpoint, usage):
151
+ outcome = replace(outcome, handed_over=True)
152
+ finally:
153
+ self.watching.discard(worker_id)
154
+ return outcome
155
+
156
+ def _launch(self, worker_id: int, span: Span, brief: str, cwd: Path, log_dir: Path) -> Outcome:
157
+ with self.lock:
158
+ if self.cancelled.is_set():
159
+ return Outcome(WorkerStatus.EXITED, 0, error=STOPPED_ERROR)
160
+ prepared = self._prepare(worker_id, span, brief, cwd, log_dir)
161
+ started = time.monotonic()
162
+ try:
163
+ handle = prepared.runtime.start(prepared.spec)
164
+ except Exception as e: # an unanswered dialog or a lost prompt: the agent did not start to work
165
+ span.error(e)
166
+ error = self._start_error(e, log_dir)
167
+ return Outcome(WorkerStatus.EXITED, time.monotonic() - started, error=error, startup_failed=True)
168
+ with self.db.session() as s:
169
+ w = s.get_one(Worker, worker_id)
170
+ w.handle, w.state = handle.data, WorkerState.RUNNING
171
+ s.commit()
172
+ self.watching.add(worker_id)
173
+ unreported = prepared.captures_session and prepared.session_id is None
174
+ return self._watch(worker_id, span, prepared.runtime, handle, prepared.timeout, log_dir, unreported=unreported)
175
+
176
+ def _reattach(self, worker_id: int, span: Span, log_dir: Path) -> Outcome:
177
+ """Watch the agent of a running row through its runtime. Its seconds count from the row's start."""
178
+ self.watching.add(worker_id)
179
+ with self.db.session() as s:
180
+ w = s.get_one(Worker, worker_id)
181
+ assert w.started_at is not None # only running rows are re-attached
182
+ elapsed = (utcnow() - w.started_at).total_seconds()
183
+ timeout = config_of(s.get_one(Project, w.project_id)).role(w.kind).timeout - elapsed
184
+ definition = harness_of(s, w)
185
+ unreported = definition is not None and definition.captures_session and w.session_id is None
186
+ runtime, handle = runtime_for(s, w, log_dir, self.runtime_factory), handle_for(w)
187
+ span.set(kind=w.kind, harness=w.harness, model=w.model, reason=w.reason, reattached=True)
188
+ outcome = self._watch(worker_id, span, runtime, handle, max(timeout, 0), log_dir, unreported=unreported)
189
+ return replace(outcome, seconds=elapsed + outcome.seconds)
190
+
191
+ def _watch(
192
+ self,
193
+ worker_id: int,
194
+ span: Span,
195
+ runtime: Runtime,
196
+ handle: Handle,
197
+ timeout: float,
198
+ log_dir: Path,
199
+ *,
200
+ unreported: bool,
201
+ ) -> Outcome:
202
+ """Wait for a started agent to end, save its transcript and release it.
203
+
204
+ When ``unreported``, record the session id that the runtime reports first.
205
+ """
206
+ try:
207
+ if unreported:
208
+ self._reported_session(worker_id, runtime, handle, span)
209
+ return runtime.wait(handle, timeout)
210
+ finally:
211
+ self._save_transcript(runtime, handle, log_dir)
212
+ runtime.kill(handle)
213
+
214
+ def _prepare(self, worker_id: int, span: Span, brief: str, cwd: Path, log_dir: Path) -> Prepared:
215
+ """Pick the harness and model, fill in the row, mark it ``starting`` and emit ``<kind>.started``."""
216
+ with self.db.session() as s:
217
+ w = s.get_one(Worker, worker_id)
218
+ project = s.get_one(Project, w.project_id)
219
+ cfg = config_of(project)
220
+ harness = w.harness or rotate(s, w, cfg.role_harnesses(w.kind))
221
+ model = w.model or cfg.role_models(w.kind)[harness]
222
+ definition = cfg.harness_for(harness)
223
+ template = definition.interactive if cfg.runtime is RuntimeKind.HERDR else definition.headless
224
+ session_id = str(uuid4()) if definition.captures_session and takes_session_id(template) else None
225
+ w.harness, w.model, w.runtime, w.log_dir = harness, model, cfg.runtime.value, str(log_dir)
226
+ w.state, w.started_at, w.session_id = WorkerState.STARTING, utcnow(), session_id
227
+ emit(
228
+ s,
229
+ f'{w.kind}.started',
230
+ TargetKind.WORKER,
231
+ worker_id,
232
+ payload={'harness': harness, 'model': model, 'reason': w.reason},
233
+ )
234
+ s.commit()
235
+ kind, reason, project_name, project_path = w.kind, w.reason, project.name, Path(project.path)
236
+ label = _label(s, w)
237
+ actor = f'{role_of(s, w).name}#{worker_id}'
238
+ span.set(kind=kind, harness=harness, model=model, reason=reason, session_id=session_id)
239
+ log_dir.mkdir(parents=True, exist_ok=True)
240
+ (log_dir / BRIEF_FILE).write_text(brief)
241
+ prompt = PROMPT.format(path=log_dir / BRIEF_FILE)
242
+ # A Herdr pane does not inherit the daemon's environment. Pass the daemon's PATH, where the harness CLIs and
243
+ # uv were found, and the variables that the role's MCP servers name.
244
+ servers = cfg.role_servers(kind)
245
+ env = {
246
+ 'PILOOP_HOME': str(self.home),
247
+ 'PILOOP_ACTOR': actor,
248
+ 'PATH': child_env()['PATH'],
249
+ **dict.fromkeys(CLEARED_ENV, ''),
250
+ **forwarded_env(servers, child_env()),
251
+ }
252
+ argv = [
253
+ fill(a, model=model, prompt=prompt, log_dir=str(log_dir), session_id=session_id or '', cwd=str(cwd))
254
+ for a in template
255
+ ]
256
+ mcp = (
257
+ RENDERERS[definition.mcp].render(
258
+ Launch(servers, definition.executable, cwd, log_dir, child_env() | env, argv)
259
+ )
260
+ if definition.mcp is not None
261
+ else Rendered([])
262
+ )
263
+ spec = WorkerSpec(
264
+ name=f'{kind}-{worker_id}',
265
+ label=label,
266
+ executable=definition.executable,
267
+ args=expand(argv, mcp),
268
+ prompt=prompt,
269
+ cwd=cwd,
270
+ log_dir=log_dir,
271
+ env=env | mcp.env,
272
+ startup_dialogs=definition.startup_dialogs,
273
+ session_id_pattern=definition.session_id_pattern,
274
+ )
275
+ runtime = self.runtime_factory(cfg.runtime, project=project_name, project_path=project_path)
276
+ return Prepared(spec, runtime, cfg.role(kind).timeout, definition.captures_session, session_id)
277
+
278
+ def _reported_session(self, worker_id: int, runtime: Runtime, handle: Handle, span: Span) -> None:
279
+ """Record the session id that the runtime reports for the started agent.
280
+
281
+ Without an id, the session cannot be attached. Then emit an event and leave the row's id NULL.
282
+ """
283
+ session_id = runtime.session_id(handle)
284
+ if session_id is None:
285
+ span.event('worker.session_unknown', worker=worker_id, runtime=runtime.kind)
286
+ return
287
+ span.set(session_id=session_id)
288
+ with self.lock, self.db.session() as s:
289
+ s.get_one(Worker, worker_id).session_id = session_id
290
+ s.commit()
291
+
292
+ def _start_error(self, e: Exception, log_dir: Path) -> str:
293
+ """Return the row's error for a failed start.
294
+
295
+ A ``StartFailed`` screen becomes the transcript, and its last lines end the error. The pane is gone now, so this
296
+ screen is the only record of what the agent was stuck on.
297
+ """
298
+ if not isinstance(e, StartFailed):
299
+ return f'{type(e).__name__}: {e}'
300
+ (log_dir / TRANSCRIPT).write_text(e.screen)
301
+ tail = [line for line in e.screen.splitlines() if line.strip()][-START_SCREEN_LINES:]
302
+ return '\n'.join([str(e), 'Its screen ended with:', *tail]) if tail else str(e)
303
+
304
+ def _save_transcript(self, runtime: Runtime, handle: Handle, log_dir: Path) -> None:
305
+ try:
306
+ (log_dir / TRANSCRIPT).write_text(runtime.read(handle, lines=TRANSCRIPT_LINES))
307
+ except Exception as e: # the pane can already be gone; a missing transcript is not an error
308
+ self.tracer.event('worker.transcript_lost', error=str(e))
309
+
310
+ def _usage(self, worker_id: int, cwd: Path, log_dir: Path, span: Span) -> UsageJson | None:
311
+ """Read the ended agent's tokens as its harness's ``usage`` spec says. A failure is an event, and stays NULL.
312
+
313
+ This runs while the worktree still exists, because a session store can be keyed by the agent's cwd. A harness
314
+ without a spec records nothing. An agent that never ran (no harness picked) has nothing to read.
315
+ """
316
+ with self.db.session() as s:
317
+ w = s.get_one(Worker, worker_id)
318
+ if w.harness is None:
319
+ return None
320
+ found = usage_of(s, w, cwd)
321
+ if found is None:
322
+ return None
323
+ if isinstance(found, UsageError):
324
+ span.event('worker.usage_unknown', worker=worker_id, error=str(found))
325
+ return None
326
+ span.set(tokens=found.total)
327
+ return found.as_json()
328
+
329
+ def _never_worked(self, worker_id: int, outcome: Outcome, usage: UsageJson | None, cwd: Path) -> bool:
330
+ """Return whether a started agent ended within ``STARTUP_SECONDS`` without a token and left nothing behind.
331
+
332
+ Nothing behind means no commit (its checkout's HEAD did not move) and no comment signed by it. Herdr reports
333
+ some first-launch dialogs as idle, so such an agent looks finished. Only a harness whose usage is read can tell
334
+ "spent nothing" from "unknown". The tokens can be unknown for an agent that worked too: Herdr reports no
335
+ session for a harness without Herdr hooks. So what the agent left decides.
336
+ """
337
+ if outcome.status is WorkerStatus.TIMED_OUT or outcome.seconds >= STARTUP_SECONDS:
338
+ return False
339
+ if (usage is not None and usage['total'] > 0) or git.moved(cwd):
340
+ return False
341
+ with self.db.session() as s:
342
+ w = s.get_one(Worker, worker_id)
343
+ definition = harness_of(s, w)
344
+ if definition is None or definition.usage is None:
345
+ return False
346
+ signed = select(Comment.id).where(Comment.author == f'{role_of(s, w).name}#{worker_id}').limit(1)
347
+ return s.scalar(signed) is None
348
+
349
+ def _attach(self, worker_id: int, cwd: Path, span: Span) -> str | None:
350
+ """Attach the ended session to Entire from ``cwd``. A failure is an event, and the row's checkpoint stays NULL.
351
+
352
+ Nothing retries the attach: the session stays readable in the harness's own store.
353
+ """
354
+ with self.db.session() as s:
355
+ w = s.get_one(Worker, worker_id)
356
+ target = attachable(w, harness_of(s, w))
357
+ if target is None:
358
+ return None
359
+ session_id, agent = target
360
+ if self.entire_version is not None and not usable(self.entire_version):
361
+ error = f'entire {self.entire_version} is older than {MIN_ENTIRE}; upgrade it: {ENTIRE_INSTALL}'
362
+ span.event('worker.session_unattached', worker=worker_id, session_id=session_id, error=error)
363
+ return None
364
+ try:
365
+ attached = attach_session(session_id, agent, cwd)
366
+ except AttachError as e:
367
+ span.event('worker.session_unattached', worker=worker_id, session_id=session_id, error=str(e))
368
+ return None
369
+ span.event(
370
+ 'worker.session_attached',
371
+ worker=worker_id,
372
+ session_id=session_id,
373
+ checkpoint=attached.checkpoint_id,
374
+ agent=attached.agent,
375
+ tokens=attached.tokens,
376
+ entire=self.entire_version,
377
+ )
378
+ return attached.checkpoint_id
379
+
380
+ def _close(
381
+ self,
382
+ worker_id: int,
383
+ outcome: Outcome,
384
+ killed: bool,
385
+ span: Span,
386
+ checkpoint: str | None,
387
+ usage: UsageJson | None,
388
+ ) -> bool:
389
+ """Close the row and emit ``<kind>.finished``, and ``STARTUP_ANOMALY`` when it applies.
390
+
391
+ After ``cancel``, the daemon's stop owns the row. Return True when the row stays open for the next daemon: a
392
+ hand-over came first and the agent had started. Then this thread does not touch the row again, whatever the
393
+ next daemon did with it.
394
+ """
395
+ with self.lock:
396
+ if self.cancelled.is_set():
397
+ if self.handover.is_set() and worker_id in self.watching:
398
+ span.set(state='handed_over')
399
+ return True
400
+ span.set(state=WorkerState.KILLED, error=STOPPED_ERROR)
401
+ return False
402
+ with self.db.session() as s:
403
+ w = s.get_one(Worker, worker_id)
404
+ w.state = WorkerState.KILLED if killed else WorkerState.EXITED
405
+ w.ended_at, w.error, w.checkpoint_id, w.usage = utcnow(), outcome.error, checkpoint, usage
406
+ emit(
407
+ s,
408
+ f'{w.kind}.finished',
409
+ TargetKind.WORKER,
410
+ worker_id,
411
+ payload={'reason': w.reason, 'status': outcome.status, 'error': outcome.error},
412
+ )
413
+ if outcome.startup_failed:
414
+ anomaly: dict[str, object] = {
415
+ 'kind': w.kind,
416
+ 'harness': w.harness,
417
+ 'run': w.run_id,
418
+ 'seconds': round(outcome.seconds, 1),
419
+ 'error': outcome.error,
420
+ }
421
+ emit(s, STARTUP_ANOMALY, TargetKind.WORKER, worker_id, payload=anomaly)
422
+ span.event(STARTUP_ANOMALY, worker=worker_id, harness=w.harness, error=outcome.error)
423
+ s.commit()
424
+ span.set(state=w.state, error=outcome.error, checkpoint=checkpoint)
425
+ return False
426
+
427
+
428
+ def rotate(s: Session, worker: Worker, harnesses: list[str]) -> str:
429
+ """Return the harness for a worker: the n-th agent of a kind in a project takes the n-th harness, round robin."""
430
+ earlier = s.scalar(
431
+ select(func.count())
432
+ .select_from(Worker)
433
+ .where(Worker.project_id == worker.project_id, Worker.kind == worker.kind, Worker.id < worker.id)
434
+ )
435
+ return harnesses[(earlier or 0) % len(harnesses)]
436
+
437
+
438
+ def _label(s: Session, w: Worker) -> str:
439
+ """Return the tab label: ``worker-12 idea#5 Stacking with a meta-learner…``, or ``research-7`` for a sweep."""
440
+ if w.run_id is None:
441
+ return f'{w.kind}-{w.id}'
442
+ idea = s.get_one(Run, w.run_id).idea
443
+ return f'{w.kind}-{w.id} idea#{idea.id} {cell(idea.title, TAB_TITLE_CHARS)}'