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.
- piloop/__init__.py +0 -0
- piloop/__main__.py +3 -0
- piloop/agents/__init__.py +1 -0
- piloop/agents/capture.py +118 -0
- piloop/agents/launcher.py +443 -0
- piloop/agents/prompts/research.md +229 -0
- piloop/agents/prompts/research_scout.md +129 -0
- piloop/agents/prompts/sql_procedures.md +295 -0
- piloop/agents/prompts/watchdog.md +30 -0
- piloop/agents/prompts/watchdog_incident.md +23 -0
- piloop/agents/prompts/watchdog_progress.md +25 -0
- piloop/agents/prompts/worker.md +94 -0
- piloop/agents/refill.py +33 -0
- piloop/agents/roles.py +214 -0
- piloop/agents/scout.py +159 -0
- piloop/campaign/__init__.py +1 -0
- piloop/campaign/ideas.py +347 -0
- piloop/campaign/inbox.py +235 -0
- piloop/campaign/project.py +242 -0
- piloop/campaign/spend.py +143 -0
- piloop/campaign/stop.py +147 -0
- piloop/campaign/templates/enwik8/.entire/.gitignore +5 -0
- piloop/campaign/templates/enwik8/.entire/settings.json +1 -0
- piloop/campaign/templates/enwik8/brief.md +32 -0
- piloop/campaign/templates/enwik8/compressor.py +13 -0
- piloop/campaign/templates/enwik8/evaluate.py +69 -0
- piloop/campaign/templates/enwik8/piloop.toml +31 -0
- piloop/campaign/templates/enwik8/pyproject.toml +5 -0
- piloop/campaign/templates/project/.entire/.gitignore +5 -0
- piloop/campaign/templates/project/.entire/settings.json +1 -0
- piloop/campaign/templates/project/brief.md +47 -0
- piloop/campaign/templates/project/evaluate.py +25 -0
- piloop/campaign/templates/project/piloop.toml +94 -0
- piloop/campaign/templates/titanic-tracks/.entire/.gitignore +5 -0
- piloop/campaign/templates/titanic-tracks/.entire/settings.json +1 -0
- piloop/campaign/templates/titanic-tracks/brief.md +33 -0
- piloop/campaign/templates/titanic-tracks/briefs/accuracy.md +4 -0
- piloop/campaign/templates/titanic-tracks/briefs/cost.md +5 -0
- piloop/campaign/templates/titanic-tracks/evaluate.py +89 -0
- piloop/campaign/templates/titanic-tracks/piloop.toml +41 -0
- piloop/campaign/templates/titanic-tracks/pyproject.toml +5 -0
- piloop/campaign/templates/titanic-tracks/solve.py +31 -0
- piloop/cli/__init__.py +1 -0
- piloop/cli/agent_skills/piloop-operate/SKILL.md +375 -0
- piloop/cli/agent_skills/piloop-review/SKILL.md +275 -0
- piloop/cli/agent_skills/piloop-setup/SKILL.md +222 -0
- piloop/cli/doctor.py +274 -0
- piloop/cli/main.py +995 -0
- piloop/cli/skills.py +85 -0
- piloop/cli/upgrade.py +31 -0
- piloop/cli/version.py +22 -0
- piloop/config.py +588 -0
- piloop/daemon/__init__.py +1 -0
- piloop/daemon/main.py +121 -0
- piloop/daemon/runner.py +732 -0
- piloop/daemon/scheduler.py +1300 -0
- piloop/daemon/serve.py +259 -0
- piloop/db/__init__.py +1 -0
- piloop/db/actor_triggers.sql +113 -0
- piloop/db/events.py +23 -0
- piloop/db/migrations/env.py +31 -0
- piloop/db/migrations/script.py.mako +24 -0
- piloop/db/migrations/versions/0001_initial.py +474 -0
- piloop/db/models.py +683 -0
- piloop/db/persistent_triggers.sql +70 -0
- piloop/db/session.py +140 -0
- piloop/db/sql.py +256 -0
- piloop/db/triggers/__init__.py +94 -0
- piloop/db/triggers/changelog.py +124 -0
- piloop/db/triggers/ddl.py +51 -0
- piloop/db/triggers/fts.py +66 -0
- piloop/db/triggers/links.py +22 -0
- piloop/db/triggers/provenance.py +51 -0
- piloop/deprecated.py +47 -0
- piloop/git.py +180 -0
- piloop/harnesses.py +234 -0
- piloop/mcp/__init__.py +173 -0
- piloop/mcp/claude.py +29 -0
- piloop/mcp/codex.py +110 -0
- piloop/mcp/pi.py +58 -0
- piloop/otel.py +109 -0
- piloop/proc.py +87 -0
- piloop/reports/__init__.py +1 -0
- piloop/reports/history.py +252 -0
- piloop/reports/overview.py +920 -0
- piloop/reports/pack.py +333 -0
- piloop/runtime/__init__.py +124 -0
- piloop/runtime/factory.py +48 -0
- piloop/runtime/herdr.py +775 -0
- piloop/runtime/subprocess.py +142 -0
- piloop/scoring/__init__.py +1 -0
- piloop/scoring/evaluate.py +151 -0
- piloop/scoring/ledger.py +496 -0
- piloop/scoring/verdict.py +160 -0
- piloop/settings.py +116 -0
- piloop/text.py +23 -0
- piloop/usage/__init__.py +180 -0
- piloop/usage/jsonl.py +276 -0
- piloop/usage/tallies/__init__.py +10 -0
- piloop/usage/tallies/claude.py +45 -0
- piloop/usage/tallies/codex.py +52 -0
- piloop/usage/tallies/pi.py +38 -0
- piloop/watch/__init__.py +1 -0
- piloop/watch/app.py +545 -0
- piloop/watch/panels.py +376 -0
- piloop-0.1.0.dist-info/METADATA +147 -0
- piloop-0.1.0.dist-info/RECORD +110 -0
- piloop-0.1.0.dist-info/WHEEL +4 -0
- piloop-0.1.0.dist-info/entry_points.txt +2 -0
- piloop-0.1.0.dist-info/licenses/LICENSE +21 -0
piloop/__init__.py
ADDED
|
File without changes
|
piloop/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The agents piloop launches: the roles and their briefs, the one lifecycle, session capture and token usage."""
|
piloop/agents/capture.py
ADDED
|
@@ -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)}'
|