ergane-cli 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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- factory/workgraph/worktree.py +1322 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
"""The activity surface of dispatch: resolve, prepare, run, salvage, remove.
|
|
2
|
+
|
|
3
|
+
`factory/workgraph/` is a library — a validator, a git wrapper, an adapter. This
|
|
4
|
+
module is where those become things a workflow can call, which means it owns the
|
|
5
|
+
two concerns the library deliberately does not: reading the world at a known
|
|
6
|
+
moment, and turning a library exception into an error the interpreter can branch
|
|
7
|
+
on without reading prose.
|
|
8
|
+
|
|
9
|
+
The activities are one node's life, in order. `resolve_graph` reads
|
|
10
|
+
`personas.yaml` once per epic and hands back a snapshot, so an operator editing
|
|
11
|
+
the registry mid-epic changes the *next* epic — the same discipline 002 applies
|
|
12
|
+
to criteria; `resolve_persona` is that read for the one role no node names, the
|
|
13
|
+
judge, which needs an alias and a key of its own without being routed to.
|
|
14
|
+
`prepare_worktree` opens (or re-opens) the node's one worktree.
|
|
15
|
+
`run_agent_attempt` is the only place an agent runs. `read_worktree_diff` is the
|
|
16
|
+
one place the resulting patch is read, because the workflow that hands it to the
|
|
17
|
+
judge may touch nothing itself. `salvage_worktree` and `remove_worktree` are the
|
|
18
|
+
terminal pair constitution VI requires on every path out, in that order.
|
|
19
|
+
|
|
20
|
+
Four things decided here rather than in the library:
|
|
21
|
+
|
|
22
|
+
- **Where `.factory/` is.** The library takes a root; the activities resolve one,
|
|
23
|
+
from `FACTORY_ROOT` or the documented default. Everything that reads this state
|
|
24
|
+
— the operator, the next worker, the sweep — has to agree with the writer about
|
|
25
|
+
the location, so there is exactly one function that answers the question.
|
|
26
|
+
|
|
27
|
+
- **Which failures are worth a retry.** A graph the registry cannot resolve, a
|
|
28
|
+
declared standards document that is not in the worktree, an agent binary that
|
|
29
|
+
is not on the host: all non-retryable, because re-running reproduces them
|
|
30
|
+
exactly and the ladder's budget exists for proxies and worktrees, not for
|
|
31
|
+
typos. A git command that failed stays retryable — a lock, a full disk and a
|
|
32
|
+
slow filesystem are all things a second attempt fixes.
|
|
33
|
+
|
|
34
|
+
- **What a rejection says.** `GRAPH_INVALID` carries the validator's message
|
|
35
|
+
verbatim, and that message names the offending node. An operator holding a
|
|
36
|
+
ten-node epic needs the offender, not "invalid workgraph". There is one error
|
|
37
|
+
type for every way a graph can fail to resolve, including an unreadable
|
|
38
|
+
registry, because the workflow's response to all of them is identical — the
|
|
39
|
+
epic does not start — and the message already says which file to go and edit.
|
|
40
|
+
|
|
41
|
+
- **How a kill reports itself.** Cancellation is how `kill_epic` reaches a
|
|
42
|
+
running agent. The adapter kills the process group and archives the evidence
|
|
43
|
+
before it re-raises; this module turns that into a `CancelledError` carrying
|
|
44
|
+
the `AdapterResult` in its details, so the workflow's kill sequence records the
|
|
45
|
+
KILLED classification the adapter *observed* rather than inferring it from the
|
|
46
|
+
fact that it asked. Temporal still sees a cancelled activity, which is what
|
|
47
|
+
makes the kill visible in the epic's history.
|
|
48
|
+
|
|
49
|
+
`load_prompt_sources` is the odd one out: a pure read, no worktree, no agent. It
|
|
50
|
+
exists so `factory/workgraph/prompt.py` can stay pure (FR-006, R9) — text in,
|
|
51
|
+
prompt out — with every file read on this side of the activity boundary.
|
|
52
|
+
|
|
53
|
+
No credential is read here. The per-attempt virtual key arrives inside the
|
|
54
|
+
`AttemptContext` the workflow assembled and goes no further than the child
|
|
55
|
+
environment the adapter builds; `LITELLM_MASTER_KEY` and `TELEGRAM_BOT_TOKEN`
|
|
56
|
+
sit in the same worker environment and have no path into any of these calls
|
|
57
|
+
(constitution V).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import asyncio
|
|
63
|
+
import logging
|
|
64
|
+
import os
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from datetime import datetime, timezone
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import Awaitable, Callable
|
|
69
|
+
|
|
70
|
+
from temporalio import activity
|
|
71
|
+
from temporalio.exceptions import ApplicationError, CancelledError
|
|
72
|
+
|
|
73
|
+
from factory.activities.usage_activities import open_client
|
|
74
|
+
from factory.activities.notify_activities import ferry_read_answer, ferry_send_question
|
|
75
|
+
from factory.config import ConfigError, Persona, load_personas
|
|
76
|
+
from factory.usage.models import Termination, UsageSnapshot
|
|
77
|
+
from factory.verify.factory_yaml import (
|
|
78
|
+
MANIFEST_NAME,
|
|
79
|
+
FactoryConfigError,
|
|
80
|
+
load_factory_config,
|
|
81
|
+
resolve_manifest_path,
|
|
82
|
+
)
|
|
83
|
+
from factory.workgraph import worktree as worktrees
|
|
84
|
+
from factory.workgraph.adapter import (
|
|
85
|
+
DEFAULT_HEARTBEAT_INTERVAL_S,
|
|
86
|
+
AdapterError,
|
|
87
|
+
ClaudeCodeAdapter,
|
|
88
|
+
adapter_for,
|
|
89
|
+
transcript_dir,
|
|
90
|
+
)
|
|
91
|
+
from factory.workgraph.models import (
|
|
92
|
+
AdapterResult,
|
|
93
|
+
AttemptContext,
|
|
94
|
+
ResolvedNode,
|
|
95
|
+
ResolvedPersona,
|
|
96
|
+
WorkGraph,
|
|
97
|
+
WorkGraphError,
|
|
98
|
+
WorkNode,
|
|
99
|
+
resolve_timeout_s,
|
|
100
|
+
validate_workgraph,
|
|
101
|
+
)
|
|
102
|
+
from factory.env import (
|
|
103
|
+
ERGANE_ROOT_ENV,
|
|
104
|
+
FACTORY_ROOT_ENV,
|
|
105
|
+
resolve_env_path,
|
|
106
|
+
)
|
|
107
|
+
from factory.workgraph.worktree import (
|
|
108
|
+
DEFAULT_FACTORY_ROOT,
|
|
109
|
+
PreparedWorktree,
|
|
110
|
+
WorktreeError,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
#: Where the salvage mirror's outcome goes. A mirror failure is reported rather
|
|
114
|
+
#: than raised (047 FR-002), so this log line is the only place an operator
|
|
115
|
+
#: learns that a terminated node's work is still on one disk.
|
|
116
|
+
logger = logging.getLogger(__name__)
|
|
117
|
+
|
|
118
|
+
#: The activity error type for a graph that must not dispatch (FR-002): an
|
|
119
|
+
#: unknown persona, a persona resolving no timeout, a dangling dependency, a
|
|
120
|
+
#: cycle, or a registry the loader refuses. Always non-retryable — the graph and
|
|
121
|
+
#: the registry are both files, and reading them again a second later gives the
|
|
122
|
+
#: same answer.
|
|
123
|
+
GRAPH_INVALID = "GRAPH_INVALID"
|
|
124
|
+
|
|
125
|
+
#: The activity error type for a `factory.yaml` that declares a standards
|
|
126
|
+
#: document the worktree does not have (R11). A config error, caught before a key
|
|
127
|
+
#: is issued rather than by an agent told to read a file that is not there.
|
|
128
|
+
STANDARDS_MISSING = "STANDARDS_MISSING"
|
|
129
|
+
|
|
130
|
+
#: The activity error type for a git operation that failed. Retryable: a lock, a
|
|
131
|
+
#: full disk and a slow filesystem are all things a second attempt fixes, and a
|
|
132
|
+
#: worktree the worker could not reach this second is the workflow's retry budget
|
|
133
|
+
#: to spend, not the ladder's.
|
|
134
|
+
WORKTREE_FAILED = "WORKTREE_FAILED"
|
|
135
|
+
|
|
136
|
+
#: The activity error type for an agent that could not be started at all — no
|
|
137
|
+
#: such adapter, or no such binary on the worker host. Distinct from a non-zero
|
|
138
|
+
#: exit, which is an `AGENT_ERROR` termination and ordinary ladder input.
|
|
139
|
+
AGENT_LAUNCH_FAILED = "AGENT_LAUNCH_FAILED"
|
|
140
|
+
|
|
141
|
+
#: The activity error type for authored text the prompt cannot be assembled
|
|
142
|
+
#: without (contracts/prompt-assembly.md): a missing input is a loud dispatch
|
|
143
|
+
#: failure, never an omitted section.
|
|
144
|
+
PROMPT_SOURCE_MISSING = "PROMPT_SOURCE_MISSING"
|
|
145
|
+
|
|
146
|
+
#: Where the worker host keeps worktrees, transcripts and pid files when it does
|
|
147
|
+
#: not say otherwise. One override, one default, one resolver (`factory_root`).
|
|
148
|
+
FACTORY_ROOT_ENV = FACTORY_ROOT_ENV # re-export for existing callers
|
|
149
|
+
ERGANE_ROOT_ENV = ERGANE_ROOT_ENV # re-export for tests and callers
|
|
150
|
+
|
|
151
|
+
#: How often a running attempt tells Temporal it is alive. At module scope so a
|
|
152
|
+
#: test can shrink it without waiting out a production-sized interval, and read
|
|
153
|
+
#: at call time so shrinking it works at all.
|
|
154
|
+
HEARTBEAT_INTERVAL_S = DEFAULT_HEARTBEAT_INTERVAL_S
|
|
155
|
+
|
|
156
|
+
#: The adapter every producing persona in the shipped registry names (D-018).
|
|
157
|
+
#: `AttemptContext` carries no `agent` field, so the seam is exercised here
|
|
158
|
+
#: rather than per attempt; a second agent adds a class and a lookup, not an
|
|
159
|
+
#: orchestration change.
|
|
160
|
+
DEFAULT_AGENT = ClaudeCodeAdapter.name
|
|
161
|
+
|
|
162
|
+
#: The epic's authored text, under `<specs_root>/<feature>/`. `spec.md` is the
|
|
163
|
+
#: system of record for intent (D-023); the other two are the clarified context
|
|
164
|
+
#: set and the node's task slice.
|
|
165
|
+
SPEC_FILENAME = "spec.md"
|
|
166
|
+
PLAN_FILENAME = "plan.md"
|
|
167
|
+
TASKS_FILENAME = "tasks.md"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def factory_root() -> Path:
|
|
171
|
+
"""The worker host's state directory (plan.md § Storage).
|
|
172
|
+
|
|
173
|
+
Relative by default, so it resolves against the worker's working directory
|
|
174
|
+
exactly the way 001's ledger and 002's evidence store do. Honors the legacy
|
|
175
|
+
`FACTORY_ROOT` env name and reports it once per process.
|
|
176
|
+
"""
|
|
177
|
+
root, _choice, _source = worktrees.resolve_factory_root(FACTORY_ROOT_ENV)
|
|
178
|
+
return root
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _usage_reader(key: str) -> Callable[[], Awaitable[UsageSnapshot]]:
|
|
182
|
+
"""A spend read for `key`, one client per call (plan US1, R9).
|
|
183
|
+
|
|
184
|
+
The adapter's monitor reads usage on its own `poll_interval_s` cadence and
|
|
185
|
+
carries the newest snapshot as heartbeat details; this is the production
|
|
186
|
+
closure the workflow's poll activity used to be. It reads the attempt's
|
|
187
|
+
virtual key through the proxy's master-key client, opens and closes a client
|
|
188
|
+
per read so a long attempt does not hold a connection open, and stamps the
|
|
189
|
+
snapshot with the moment it was true — a value teardown may record hours
|
|
190
|
+
later is only honest if its staleness is visible.
|
|
191
|
+
|
|
192
|
+
A failure raises the client's own `LiteLLMError`; the adapter isolates it so
|
|
193
|
+
a dead spend read never kills the beat.
|
|
194
|
+
"""
|
|
195
|
+
async def read() -> UsageSnapshot:
|
|
196
|
+
client = open_client()
|
|
197
|
+
try:
|
|
198
|
+
spend_usd = await client.get_spend(key)
|
|
199
|
+
finally:
|
|
200
|
+
await client.aclose()
|
|
201
|
+
return UsageSnapshot(
|
|
202
|
+
spend_usd=spend_usd,
|
|
203
|
+
captured_at=datetime.now(timezone.utc)
|
|
204
|
+
.isoformat(timespec="seconds")
|
|
205
|
+
.replace("+00:00", "Z"),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return read
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ferry_sender(
|
|
212
|
+
context: AttemptContext,
|
|
213
|
+
) -> Callable[[str], Awaitable[str]] | None:
|
|
214
|
+
"""The ferry's send callable, bound to this attempt's identity (008-US3).
|
|
215
|
+
|
|
216
|
+
Closes over the workflow id (the activity's own info), the epic/node/attempt
|
|
217
|
+
the question is attributed to (FR-002), and the question text the agent
|
|
218
|
+
writes — so the monitor loop calls one argument (the text) and the rest is
|
|
219
|
+
already pinned. Returns ``None`` when the workflow id is unavailable (a
|
|
220
|
+
bare-`ActivityEnvironment` test with no info), so the ferry degrades to the
|
|
221
|
+
US1-only path rather than raising: a sender that cannot attribute a question
|
|
222
|
+
ships nothing, and the agent degrades to the marker path if the window
|
|
223
|
+
elapses (FR-009).
|
|
224
|
+
"""
|
|
225
|
+
workflow_id = activity.info().workflow_id if activity.in_activity() else None
|
|
226
|
+
if workflow_id is None:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
async def send(question_text: str) -> str:
|
|
230
|
+
return await ferry_send_question(
|
|
231
|
+
workflow_id,
|
|
232
|
+
context.epic_id,
|
|
233
|
+
context.node_id,
|
|
234
|
+
context.attempt,
|
|
235
|
+
question_text,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return send
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- resolve_graph (the registry snapshot) ------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@activity.defn
|
|
245
|
+
async def resolve_graph(graph: WorkGraph) -> list[ResolvedNode]:
|
|
246
|
+
"""Read the persona registry against the graph, once, at epic start.
|
|
247
|
+
|
|
248
|
+
Returns one `ResolvedNode` per node in declaration order (R10), each carrying
|
|
249
|
+
the model alias, the issued key's model list, the write scope and the
|
|
250
|
+
resolved timeout. The node travels through untouched: what was validated is
|
|
251
|
+
what dispatches.
|
|
252
|
+
|
|
253
|
+
Read-only and idempotent — no filesystem beyond `personas.yaml`, no git, no
|
|
254
|
+
target repo — so Temporal may run it twice and both runs agree.
|
|
255
|
+
|
|
256
|
+
Raises non-retryable `GRAPH_INVALID`, naming the offending node, for anything
|
|
257
|
+
that would leave a node undispatchable.
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
registry = load_personas()
|
|
261
|
+
except ConfigError as exc:
|
|
262
|
+
# The loader's message already names the registry file and the persona;
|
|
263
|
+
# a graph whose registry will not load is as undispatchable as one with
|
|
264
|
+
# a dangling edge, and the workflow's response to both is the same.
|
|
265
|
+
raise _graph_invalid(str(exc)) from exc
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
validate_workgraph(graph, registry)
|
|
269
|
+
except WorkGraphError as exc:
|
|
270
|
+
raise _graph_invalid(str(exc)) from exc
|
|
271
|
+
|
|
272
|
+
return [_resolve_node(node, registry[node.persona]) for node in graph.nodes]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _resolve_node(node: WorkNode, persona: Persona) -> ResolvedNode:
|
|
276
|
+
"""One node with the registry read against it (data-model.md § ResolvedNode).
|
|
277
|
+
|
|
278
|
+
`models` is primary-then-fallback with nothing invented for a persona that
|
|
279
|
+
declares neither: it becomes the issued key's constraint list (001), so an
|
|
280
|
+
alias appearing here that the persona did not name would widen what the
|
|
281
|
+
attempt may call.
|
|
282
|
+
"""
|
|
283
|
+
timeout_s = resolve_timeout_s(node, persona)
|
|
284
|
+
if persona.model is None or timeout_s is None:
|
|
285
|
+
# `validate_workgraph` has already rejected an unresolvable timeout, so
|
|
286
|
+
# reaching here means a deterministic persona carrying a per-story
|
|
287
|
+
# override: routable on paper, and with no model to dispatch under.
|
|
288
|
+
raise _graph_invalid(
|
|
289
|
+
f"node '{node.id}': persona '{node.persona}' runs no agent and names "
|
|
290
|
+
"no model — a producing node cannot be routed to it (constitution VII)"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return ResolvedNode(
|
|
294
|
+
node=node,
|
|
295
|
+
model_alias=persona.model,
|
|
296
|
+
models=[alias for alias in (persona.model, persona.fallback) if alias],
|
|
297
|
+
write_scope=persona.write_scope.value,
|
|
298
|
+
timeout_s=timeout_s,
|
|
299
|
+
context_window=persona.context_window,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _graph_invalid(message: str) -> ApplicationError:
|
|
304
|
+
return ApplicationError(message, type=GRAPH_INVALID, non_retryable=True)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- resolve_persona (the roles no node names) --------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(frozen=True)
|
|
311
|
+
class ResolvePersonaInput:
|
|
312
|
+
"""One registry entry to resolve, by name."""
|
|
313
|
+
|
|
314
|
+
persona: str
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@activity.defn
|
|
318
|
+
async def resolve_persona(request: ResolvePersonaInput) -> ResolvedPersona:
|
|
319
|
+
"""Read one persona the graph does not name — the judge (constitution V).
|
|
320
|
+
|
|
321
|
+
`resolve_graph` answers for nodes, and no node is routed to the judge: it
|
|
322
|
+
scores what an implementer produced, on its own key and under its own alias.
|
|
323
|
+
Called once at epic start for the same reason the graph is resolved there —
|
|
324
|
+
an epic that cannot score its stories should fail before a key is spent
|
|
325
|
+
discovering it, not four attempts in.
|
|
326
|
+
|
|
327
|
+
Read-only and idempotent. Raises non-retryable `GRAPH_INVALID`, naming the
|
|
328
|
+
persona, when the registry has no such entry or the entry names no model.
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
registry = load_personas()
|
|
332
|
+
except ConfigError as exc:
|
|
333
|
+
raise _graph_invalid(str(exc)) from exc
|
|
334
|
+
|
|
335
|
+
persona = registry.get(request.persona)
|
|
336
|
+
if persona is None:
|
|
337
|
+
raise _graph_invalid(
|
|
338
|
+
f"persona '{request.persona}' is not in the registry, so the work "
|
|
339
|
+
"that needs it cannot be dispatched"
|
|
340
|
+
)
|
|
341
|
+
if persona.model is None:
|
|
342
|
+
raise _graph_invalid(
|
|
343
|
+
f"persona '{request.persona}' runs no agent and names no model — "
|
|
344
|
+
"nothing can be dispatched to it (constitution VII)"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return ResolvedPersona(
|
|
348
|
+
persona=request.persona,
|
|
349
|
+
model_alias=persona.model,
|
|
350
|
+
models=[alias for alias in (persona.model, persona.fallback) if alias],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --- prepare_worktree (FR-013, R11) -------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass(frozen=True)
|
|
358
|
+
class PrepareWorktreeInput:
|
|
359
|
+
"""Which node's worktree to open, and what the target repo demands be in it.
|
|
360
|
+
|
|
361
|
+
`standards` is the `factory.yaml` path (R11) rather than its contents: the
|
|
362
|
+
prompt points the agent at the document, and the agent reads it in the
|
|
363
|
+
worktree. `None` means the repo declares nothing to obey, which is the
|
|
364
|
+
ordinary case.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
epic_id: str
|
|
368
|
+
node_id: str
|
|
369
|
+
target_repo: str
|
|
370
|
+
standards: str | None = None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@activity.defn
|
|
374
|
+
async def prepare_worktree(request: PrepareWorktreeInput) -> PreparedWorktree:
|
|
375
|
+
"""Open the node's one worktree, creating it on first dispatch (FR-013).
|
|
376
|
+
|
|
377
|
+
Idempotent across attempts and across activity re-runs: an existing worktree
|
|
378
|
+
is returned untouched, with the base ref it was pinned to at creation, so an
|
|
379
|
+
attempt opens the tree the previous attempt left behind.
|
|
380
|
+
|
|
381
|
+
Raises non-retryable `STANDARDS_MISSING` when the target repo declares a
|
|
382
|
+
standards document the worktree does not have, and retryable
|
|
383
|
+
`WORKTREE_FAILED` when git itself refused.
|
|
384
|
+
|
|
385
|
+
Runs in a worker thread: a first checkout of a large repository owns the wall
|
|
386
|
+
clock for as long as it takes, and blocking the event loop would stall every
|
|
387
|
+
other activity this worker is running.
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
390
|
+
prepared = await asyncio.to_thread(
|
|
391
|
+
worktrees.ensure,
|
|
392
|
+
request.target_repo,
|
|
393
|
+
request.epic_id,
|
|
394
|
+
request.node_id,
|
|
395
|
+
factory_root=factory_root(),
|
|
396
|
+
)
|
|
397
|
+
except WorktreeError as exc:
|
|
398
|
+
raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
|
|
399
|
+
|
|
400
|
+
_require_standards(request, prepared)
|
|
401
|
+
return prepared
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _require_standards(
|
|
405
|
+
request: PrepareWorktreeInput, prepared: PreparedWorktree
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Refuse a dispatch whose declared standards document is not there (R11).
|
|
408
|
+
|
|
409
|
+
Checked in the worktree the agent will actually see rather than in the clone
|
|
410
|
+
it was branched from: those differ the moment a node's own attempt touches
|
|
411
|
+
the file. The message names both the path and the node, because either one of
|
|
412
|
+
them may be the thing that is wrong.
|
|
413
|
+
"""
|
|
414
|
+
if not request.standards:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
document = Path(prepared.path) / request.standards
|
|
418
|
+
if document.is_file():
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
raise ApplicationError(
|
|
422
|
+
f"node '{request.node_id}': {MANIFEST_NAME} declares "
|
|
423
|
+
f"standards '{request.standards}', which is not in the node's worktree "
|
|
424
|
+
f"(looked for {document})",
|
|
425
|
+
type=STANDARDS_MISSING,
|
|
426
|
+
non_retryable=True,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --- run_agent_attempt (the one place an agent runs) --------------------------
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@activity.defn
|
|
434
|
+
async def run_agent_attempt(context: AttemptContext) -> AdapterResult:
|
|
435
|
+
"""Run one agent attempt to its end, whatever that end is (D-018, FR-005).
|
|
436
|
+
|
|
437
|
+
Returns the termination class and the archived transcript directory, and
|
|
438
|
+
nothing else: the diff is read from the worktree and usage from the ledger,
|
|
439
|
+
and nothing the agent *said* reaches node state (FR-012).
|
|
440
|
+
|
|
441
|
+
Beats roughly every `HEARTBEAT_INTERVAL_S` while the agent works, which is
|
|
442
|
+
what keeps a multi-hour attempt alive in Temporal's eyes and what makes a
|
|
443
|
+
cancellation land within one interval instead of at the deadline.
|
|
444
|
+
|
|
445
|
+
On cancellation the adapter has already ended the process group and archived
|
|
446
|
+
the evidence; this re-raises as `CancelledError` carrying the `AdapterResult`
|
|
447
|
+
so the kill sequence records the classification the adapter observed. Raises
|
|
448
|
+
non-retryable `AGENT_LAUNCH_FAILED` when there was no agent to run.
|
|
449
|
+
"""
|
|
450
|
+
root = factory_root()
|
|
451
|
+
try:
|
|
452
|
+
# The launch backend is resolved from the target repo's `runtime:` key
|
|
453
|
+
# by the adapter itself (011-US2). Nothing overrides it here: an
|
|
454
|
+
# unconditional assignment on this line pinned every production attempt
|
|
455
|
+
# to the host launch for the whole of 011, while US3/US4/US5 proved a
|
|
456
|
+
# boundary no dispatch could reach. Tests that need the host launch
|
|
457
|
+
# declare `runtime: host` in their fixture manifest and get it through
|
|
458
|
+
# the same resolution production uses.
|
|
459
|
+
adapter = adapter_for(DEFAULT_AGENT)
|
|
460
|
+
return await adapter.run_attempt(
|
|
461
|
+
context,
|
|
462
|
+
factory_root=root,
|
|
463
|
+
heartbeat=activity.heartbeat,
|
|
464
|
+
heartbeat_interval_s=HEARTBEAT_INTERVAL_S,
|
|
465
|
+
read_usage=_usage_reader(context.virtual_key),
|
|
466
|
+
# 008-US3: the in-attempt ferry. An agent that asks mid-flight writes
|
|
467
|
+
# a `question` file to its archive directory; these callables ship it
|
|
468
|
+
# up (record + page, the same `send_question` path US1 takes) and
|
|
469
|
+
# poll the store for the operator's reply. Both are isolated by the
|
|
470
|
+
# adapter so a dead ferry call never kills the liveness beat (FR-009).
|
|
471
|
+
# The workflow id is the activity's own info — the same id the US1
|
|
472
|
+
# terminal path puts on the `SendQuestionInput` — so a ferried
|
|
473
|
+
# question is attributed to the same workflow that would have asked
|
|
474
|
+
# it at the end.
|
|
475
|
+
send_ferry_question=_ferry_sender(context),
|
|
476
|
+
read_ferry_answer=ferry_read_answer,
|
|
477
|
+
)
|
|
478
|
+
except asyncio.CancelledError:
|
|
479
|
+
raise CancelledError(
|
|
480
|
+
f"attempt {context.attempt} of {context.epic_id}/{context.node_id} "
|
|
481
|
+
"was cancelled; the agent's process group is dead and its evidence "
|
|
482
|
+
"is archived",
|
|
483
|
+
AdapterResult(
|
|
484
|
+
termination=Termination.KILLED,
|
|
485
|
+
transcript_path=str(
|
|
486
|
+
transcript_dir(
|
|
487
|
+
root, context.epic_id, context.node_id, context.attempt
|
|
488
|
+
)
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
) from None
|
|
492
|
+
except AdapterError as exc:
|
|
493
|
+
# No adapter, or no binary: the worker host is misconfigured, and the
|
|
494
|
+
# ladder must not spend one of the node's attempts discovering that.
|
|
495
|
+
raise ApplicationError(
|
|
496
|
+
str(exc), type=AGENT_LAUNCH_FAILED, non_retryable=True
|
|
497
|
+
) from exc
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# --- read_worktree_diff (what the judge scores) -------------------------------
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@dataclass(frozen=True)
|
|
504
|
+
class ReadWorktreeDiffInput:
|
|
505
|
+
"""Which worktree to read the attempt's patch out of, and against what.
|
|
506
|
+
|
|
507
|
+
The path rather than the epic/node pair, because the two callers that read
|
|
508
|
+
this worktree in the same breath — 002's `run_gates` and `check_output` —
|
|
509
|
+
take the path the workflow is already holding, and a second way of naming the
|
|
510
|
+
same directory is a second thing that can disagree.
|
|
511
|
+
|
|
512
|
+
`base_ref` is the prepared worktree's branch point (D-027): the judge scores
|
|
513
|
+
everything the attempt changed since the node began, which an agent
|
|
514
|
+
following the inner ralph contract has partly *committed* by now — a diff
|
|
515
|
+
against HEAD would hand the judge everything except that work.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
worktree_path: str
|
|
519
|
+
base_ref: str
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@activity.defn
|
|
523
|
+
async def read_worktree_diff(request: ReadWorktreeDiffInput) -> str:
|
|
524
|
+
"""Read what the attempt changed, for the judge to score (FR-001).
|
|
525
|
+
|
|
526
|
+
The workflow decides and touches nothing, so this is the one place a
|
|
527
|
+
worktree's patch is read. Read-only — the patch is assembled against a
|
|
528
|
+
scratch index, never the worktree's own — and safe to retry.
|
|
529
|
+
|
|
530
|
+
Raises retryable `WORKTREE_FAILED` when git could not read the worktree: an
|
|
531
|
+
unreachable directory resembles an empty diff exactly, and handing the judge
|
|
532
|
+
"the agent produced nothing" over a lost mount charges an infrastructure
|
|
533
|
+
failure to the attempt.
|
|
534
|
+
|
|
535
|
+
Runs in a worker thread: a patch over a large repository owns the wall clock
|
|
536
|
+
for as long as git takes.
|
|
537
|
+
"""
|
|
538
|
+
try:
|
|
539
|
+
return await asyncio.to_thread(
|
|
540
|
+
worktrees.diff, request.worktree_path, base_ref=request.base_ref
|
|
541
|
+
)
|
|
542
|
+
except WorktreeError as exc:
|
|
543
|
+
raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# --- salvage_worktree / remove_worktree (constitution VI) ---------------------
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@dataclass(frozen=True)
|
|
550
|
+
class SalvageWorktreeInput:
|
|
551
|
+
"""One terminal attempt, in the terms the salvage commit's subject needs.
|
|
552
|
+
|
|
553
|
+
`termination` is the adapter's classification, travelling from the workflow
|
|
554
|
+
to the one commit subject that will outlive `.factory/` (SC-004). It arrives
|
|
555
|
+
as a `Termination` member rather than as a list of one-character strings
|
|
556
|
+
because the enum is a `StrEnum` — see `factory/usage/models.py` for why that
|
|
557
|
+
spelling is load-bearing on any field a workflow hands to an activity.
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
epic_id: str
|
|
561
|
+
node_id: str
|
|
562
|
+
termination: Termination
|
|
563
|
+
attempt: int
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@activity.defn
|
|
567
|
+
async def salvage_worktree(request: SalvageWorktreeInput) -> str:
|
|
568
|
+
"""Commit whatever the attempt left to the node's branch, mirror it, return the sha.
|
|
569
|
+
|
|
570
|
+
Runs on every termination path before any cleanup (constitution VI), and
|
|
571
|
+
commits an empty tree as readily as a dirty one so every terminal attempt is
|
|
572
|
+
observable from the ref alone (SC-004). Idempotent per attempt: a re-run
|
|
573
|
+
after an unrecorded success lands on the same commit.
|
|
574
|
+
|
|
575
|
+
The commit is the durable artifact; the mirror is what gets it off this
|
|
576
|
+
machine (047 US1), and the per-attempt ref is what keeps the sha this
|
|
577
|
+
activity returns resolvable after the branch has moved on (047 US2). Both
|
|
578
|
+
run after the commit and outside the conversion below, unconditionally —
|
|
579
|
+
including on the retry path where `salvage` short-circuits, because a retry
|
|
580
|
+
after a failed push is exactly when a mirror is worth running, and a record
|
|
581
|
+
written only inside the commit branch would be skipped on the very path
|
|
582
|
+
Temporal takes to re-make it. Their failures are returned as data and
|
|
583
|
+
logged, never raised: an unreachable remote that failed this activity would
|
|
584
|
+
turn a successful salvage into a failed one.
|
|
585
|
+
|
|
586
|
+
The ref is written before the mirror so the mirror can carry it (FR-008).
|
|
587
|
+
|
|
588
|
+
The return value stays a plain `str` (047 FR-005). Three call sites in
|
|
589
|
+
`EpicWorkflow` already schedule this activity and discard what it answers;
|
|
590
|
+
widening the payload or adding a fourth command would be a replay hazard for
|
|
591
|
+
every in-flight epic, and nothing here needs one — a linked worktree already
|
|
592
|
+
knows its own remote.
|
|
593
|
+
"""
|
|
594
|
+
try:
|
|
595
|
+
sha = await asyncio.to_thread(
|
|
596
|
+
worktrees.salvage,
|
|
597
|
+
request.epic_id,
|
|
598
|
+
request.node_id,
|
|
599
|
+
termination=request.termination,
|
|
600
|
+
attempt=request.attempt,
|
|
601
|
+
factory_root=factory_root(),
|
|
602
|
+
)
|
|
603
|
+
except WorktreeError as exc:
|
|
604
|
+
raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
|
|
605
|
+
|
|
606
|
+
recorded = await asyncio.to_thread(
|
|
607
|
+
worktrees.record_salvage_ref,
|
|
608
|
+
request.epic_id,
|
|
609
|
+
request.node_id,
|
|
610
|
+
attempt=request.attempt,
|
|
611
|
+
sha=sha,
|
|
612
|
+
factory_root=factory_root(),
|
|
613
|
+
)
|
|
614
|
+
logger.info(
|
|
615
|
+
"salvage ref for %s/%s attempt %s: written=%s %s",
|
|
616
|
+
request.epic_id,
|
|
617
|
+
request.node_id,
|
|
618
|
+
request.attempt,
|
|
619
|
+
recorded.written,
|
|
620
|
+
recorded.detail,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
outcome = await asyncio.to_thread(
|
|
624
|
+
worktrees.mirror_node_branch,
|
|
625
|
+
request.epic_id,
|
|
626
|
+
request.node_id,
|
|
627
|
+
factory_root=factory_root(),
|
|
628
|
+
)
|
|
629
|
+
logger.info(
|
|
630
|
+
"salvage mirror for %s/%s attempt %s: pushed=%s %s; refs pushed=%s %s",
|
|
631
|
+
request.epic_id,
|
|
632
|
+
request.node_id,
|
|
633
|
+
request.attempt,
|
|
634
|
+
outcome.pushed,
|
|
635
|
+
outcome.detail,
|
|
636
|
+
outcome.refs_pushed,
|
|
637
|
+
outcome.refs_detail,
|
|
638
|
+
)
|
|
639
|
+
return sha
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@dataclass(frozen=True)
|
|
643
|
+
class RemoveWorktreeInput:
|
|
644
|
+
"""Which node's worktree to sweep, and the clone that administers it."""
|
|
645
|
+
|
|
646
|
+
epic_id: str
|
|
647
|
+
node_id: str
|
|
648
|
+
target_repo: str
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@activity.defn
|
|
652
|
+
async def remove_worktree(request: RemoveWorktreeInput) -> None:
|
|
653
|
+
"""Delete the node's worktree directory, leaving the branch and its history.
|
|
654
|
+
|
|
655
|
+
Cleanup, never deletion of the record: once `.factory/` is swept, the branch
|
|
656
|
+
and its salvage commits are the only thing left of the attempt. Idempotent —
|
|
657
|
+
an already-removed worktree, or one a node killed before dispatch never had,
|
|
658
|
+
is success.
|
|
659
|
+
"""
|
|
660
|
+
try:
|
|
661
|
+
await asyncio.to_thread(
|
|
662
|
+
worktrees.remove,
|
|
663
|
+
request.target_repo,
|
|
664
|
+
request.epic_id,
|
|
665
|
+
request.node_id,
|
|
666
|
+
factory_root=factory_root(),
|
|
667
|
+
)
|
|
668
|
+
except WorktreeError as exc:
|
|
669
|
+
raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# --- load_prompt_sources (contracts/prompt-assembly.md) -----------------------
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@dataclass(frozen=True)
|
|
676
|
+
class LoadPromptSourcesInput:
|
|
677
|
+
"""Where the epic's authored text lives, and which repo declares standards."""
|
|
678
|
+
|
|
679
|
+
specs_root: str
|
|
680
|
+
feature: str
|
|
681
|
+
target_repo: str
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@dataclass(frozen=True)
|
|
685
|
+
class PromptSources:
|
|
686
|
+
"""Everything on disk that a dispatch prompt is assembled from (R9).
|
|
687
|
+
|
|
688
|
+
Text, verbatim and whole — the story sections and the task slice are cut out
|
|
689
|
+
of it by pure functions in `factory/workgraph/prompt.py`, which is what keeps
|
|
690
|
+
assembly deterministic and unit-testable. `standards` is the declared *path*,
|
|
691
|
+
not the document: the prompt points the agent at it and the agent reads it in
|
|
692
|
+
its own worktree, where `prepare_worktree` has already confirmed it exists.
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
spec_text: str
|
|
696
|
+
plan_text: str
|
|
697
|
+
tasks_text: str
|
|
698
|
+
standards: str | None = None
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@activity.defn
|
|
702
|
+
async def load_prompt_sources(request: LoadPromptSourcesInput) -> PromptSources:
|
|
703
|
+
"""Read the epic's spec, plan and tasks, plus the target repo's standards path.
|
|
704
|
+
|
|
705
|
+
The one read-only activity in the dispatch path, and the reason prompt
|
|
706
|
+
assembly itself touches no filesystem (FR-006). Raises non-retryable
|
|
707
|
+
`PROMPT_SOURCE_MISSING` naming the path when any of the three documents is
|
|
708
|
+
absent — the assembler never invents context, so a missing input is a loud
|
|
709
|
+
dispatch failure rather than an omitted section.
|
|
710
|
+
"""
|
|
711
|
+
feature_dir = Path(request.specs_root) / request.feature
|
|
712
|
+
return PromptSources(
|
|
713
|
+
spec_text=_read_source(feature_dir / SPEC_FILENAME),
|
|
714
|
+
plan_text=_read_source(feature_dir / PLAN_FILENAME),
|
|
715
|
+
tasks_text=_read_source(feature_dir / TASKS_FILENAME),
|
|
716
|
+
standards=_declared_standards(request.target_repo),
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _read_source(path: Path) -> str:
|
|
721
|
+
"""One authored document, verbatim; absent or unreadable is a dispatch failure."""
|
|
722
|
+
try:
|
|
723
|
+
return path.read_text(encoding="utf-8")
|
|
724
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
725
|
+
raise ApplicationError(
|
|
726
|
+
f"cannot read prompt source {path}: {exc}",
|
|
727
|
+
type=PROMPT_SOURCE_MISSING,
|
|
728
|
+
non_retryable=True,
|
|
729
|
+
) from exc
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _declared_standards(target_repo: str) -> str | None:
|
|
733
|
+
"""The standards document the target repo's manifest declares, if any (R11).
|
|
734
|
+
|
|
735
|
+
A manifest that is absent or that the schema refuses reads as "declares no
|
|
736
|
+
standards" rather than failing the dispatch. That is not a swallowed error:
|
|
737
|
+
002's gate run reads the same manifest and reports an unusable one as a
|
|
738
|
+
`CONFIG_ERROR` gate result, which fails the attempt with the parser's own
|
|
739
|
+
diagnosis attached. Raising here would pre-empt that with strictly less.
|
|
740
|
+
"""
|
|
741
|
+
try:
|
|
742
|
+
manifest_path, _ = resolve_manifest_path(target_repo)
|
|
743
|
+
return load_factory_config(manifest_path).standards
|
|
744
|
+
except FactoryConfigError:
|
|
745
|
+
return None
|