ftown-bridge 0.18.2 → 0.19.1
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.
- package/dist/agent-commands.d.ts +4 -0
- package/dist/agent-commands.js +27 -4
- package/dist/agent-commands.js.map +1 -1
- package/dist/create-ftown-session.js +2 -1
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/ftown-sessions-cli.js +1 -1
- package/dist/ftown-sessions-cli.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/loop-run-store.js +1 -0
- package/dist/loop-run-store.js.map +1 -1
- package/dist/loop-validation.js +1 -0
- package/dist/loop-validation.js.map +1 -1
- package/dist/session-store.d.ts +15 -1
- package/dist/session-store.js +63 -3
- package/dist/session-store.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
- package/skills/factory/SKILL.md +167 -0
- package/skills/factory/factory-template/bin/dispatch.py +472 -0
- package/skills/factory/factory-template/factory.yaml +36 -0
- package/skills/factory/factory-template/skills/_protocol.md +85 -0
- package/skills/factory/factory-template/skills/design.md +259 -0
- package/skills/factory/factory-template/skills/digest.md +78 -0
- package/skills/factory/factory-template/skills/groom.md +243 -0
- package/skills/factory/factory-template/skills/implement.md +316 -0
- package/skills/factory/factory-template/skills/pr.md +220 -0
- package/skills/factory/factory-template/skills/qa.md +226 -0
- package/skills/factory/factory-template/skills/review.md +231 -0
- package/skills/factory/factory-template/skills/triage.md +157 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""No-LLM factory dispatcher — one tick per invocation, then exit.
|
|
3
|
+
|
|
4
|
+
Run by an ftown interval loop from a deployed project's repo root:
|
|
5
|
+
|
|
6
|
+
uv run --with fticket,pyyaml python factory/bin/dispatch.py [--dry-run]
|
|
7
|
+
|
|
8
|
+
Responsibilities in a single tick: run the fticket scheduler, dead-letter stragglers,
|
|
9
|
+
claim queued tickets up to capacity and spawn one worker session per claim (rejecting
|
|
10
|
+
first-stage tickets with a missing/empty request.md and tickets with 3+ zero-progress
|
|
11
|
+
claim expiries — both dead-lettered instead of respawned), track worker_id -> session_id
|
|
12
|
+
in .ffactory/workers.json and reap the recorded session on every event that retires a
|
|
13
|
+
worker's claim (claim expiry, dead-letter, or a normal terminal outcome — advance,
|
|
14
|
+
complete, reject, release; backstops the worker's own self-close), and forward
|
|
15
|
+
dead-letter/orphan events to the operator session. All coordination is server-side and
|
|
16
|
+
atomic (claims fence), so two dispatchers may race safely with no lockfile.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
import traceback
|
|
28
|
+
import uuid
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from fticket import FTS
|
|
35
|
+
from fticket.errors import FTSError
|
|
36
|
+
from fticket.types import EventType, HistoryKind, Status
|
|
37
|
+
|
|
38
|
+
# The ftown spawn CLI. Overridable (a test points it at a stub script via the env var).
|
|
39
|
+
FTOWN_CLI = str(Path(os.environ.get("FTOWN_SESSIONS_BIN", "~/.ftown/ftown-sessions")).expanduser())
|
|
40
|
+
|
|
41
|
+
TERMINAL_STATUSES = (Status.DONE, Status.DEAD_LETTER)
|
|
42
|
+
FORWARD_EVENTS = (EventType.TICKET_DEAD_LETTER, EventType.TICKET_ORPHANED)
|
|
43
|
+
# Reap (ftown remove) the recorded session on every lifecycle event that retires a
|
|
44
|
+
# worker's claim: crash/hang past claim TTL, ticket killed under it, or a normal
|
|
45
|
+
# terminal outcome. Self-close is LLM compliance, not a guarantee — the dispatcher backs
|
|
46
|
+
# it up by removing the session itself; an already-self-closed session errors harmlessly
|
|
47
|
+
# (non-fatal, stderr-logged in ftown_remove).
|
|
48
|
+
REAP_EVENTS = (
|
|
49
|
+
EventType.TICKET_CLAIM_EXPIRED,
|
|
50
|
+
EventType.TICKET_DEAD_LETTER,
|
|
51
|
+
EventType.TICKET_ADVANCED,
|
|
52
|
+
EventType.TICKET_COMPLETED,
|
|
53
|
+
EventType.TICKET_REJECTED,
|
|
54
|
+
EventType.TICKET_RELEASED,
|
|
55
|
+
)
|
|
56
|
+
CURSOR_FILE = Path(".ffactory") / "dispatch.cursor"
|
|
57
|
+
WORKERS_FILE = Path(".ffactory") / "workers.json"
|
|
58
|
+
DEAD_LETTER_ACTOR = "dispatcher"
|
|
59
|
+
ZERO_PROGRESS_LIMIT = 3
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class StageCfg:
|
|
64
|
+
name: str
|
|
65
|
+
harness: str
|
|
66
|
+
model: str | None
|
|
67
|
+
max_workers: int
|
|
68
|
+
next: str
|
|
69
|
+
bounce: str
|
|
70
|
+
claim_ttl_ms: int | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Limits:
|
|
75
|
+
max_sessions: int
|
|
76
|
+
bounce_limit: int
|
|
77
|
+
claim_ttl_ms: int
|
|
78
|
+
max_ticket_age_h: int
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class FactoryConfig:
|
|
83
|
+
project: str
|
|
84
|
+
operator_session: str
|
|
85
|
+
limits: Limits
|
|
86
|
+
stages: tuple[StageCfg, ...]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config(yaml_path: Path) -> FactoryConfig:
|
|
90
|
+
raw = yaml.safe_load(yaml_path.read_text())
|
|
91
|
+
limits_raw = raw.get("limits", {})
|
|
92
|
+
limits = Limits(
|
|
93
|
+
max_sessions=int(limits_raw.get("max_sessions", 1)),
|
|
94
|
+
bounce_limit=int(limits_raw.get("bounce_limit", 3)),
|
|
95
|
+
claim_ttl_ms=int(limits_raw.get("claim_ttl_ms", 1_800_000)),
|
|
96
|
+
max_ticket_age_h=int(limits_raw.get("max_ticket_age_h", 48)),
|
|
97
|
+
)
|
|
98
|
+
stages = tuple(
|
|
99
|
+
StageCfg(
|
|
100
|
+
name=str(s["name"]),
|
|
101
|
+
harness=str(s["harness"]),
|
|
102
|
+
model=(str(s["model"]) if s.get("model") else None),
|
|
103
|
+
max_workers=int(s.get("max_workers", 1)),
|
|
104
|
+
next=str(s.get("next", "-")),
|
|
105
|
+
bounce=str(s.get("bounce", "-")),
|
|
106
|
+
claim_ttl_ms=(int(s["claim_ttl_ms"]) if s.get("claim_ttl_ms") else None),
|
|
107
|
+
)
|
|
108
|
+
for s in raw.get("stages", [])
|
|
109
|
+
)
|
|
110
|
+
return FactoryConfig(
|
|
111
|
+
project=str(raw.get("project", "factory")),
|
|
112
|
+
operator_session=str(raw.get("operator_session", "-")),
|
|
113
|
+
limits=limits,
|
|
114
|
+
stages=stages,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def compose_briefing(
|
|
119
|
+
*,
|
|
120
|
+
ticket_id: int,
|
|
121
|
+
stage: StageCfg,
|
|
122
|
+
db_path: Path,
|
|
123
|
+
ticket_dir: Path,
|
|
124
|
+
repo_root: Path,
|
|
125
|
+
epoch: int,
|
|
126
|
+
worker_id: str,
|
|
127
|
+
operator_session: str,
|
|
128
|
+
) -> str:
|
|
129
|
+
lines = [
|
|
130
|
+
f"TICKET_ID={ticket_id}",
|
|
131
|
+
f"STAGE={stage.name}",
|
|
132
|
+
f"NEXT_STAGE={stage.next or '-'}",
|
|
133
|
+
f"BOUNCE_STAGE={stage.bounce or '-'}",
|
|
134
|
+
f"FTS_DB={db_path}",
|
|
135
|
+
f"TICKET_DIR={ticket_dir}",
|
|
136
|
+
f"REPO_ROOT={repo_root}",
|
|
137
|
+
f"EPOCH={epoch}",
|
|
138
|
+
f"WORKER_ID={worker_id}",
|
|
139
|
+
"",
|
|
140
|
+
"Task: Read and follow $REPO_ROOT/factory/skills/_protocol.md, "
|
|
141
|
+
f"then execute your stage per $REPO_ROOT/factory/skills/{stage.name}.md.",
|
|
142
|
+
]
|
|
143
|
+
if operator_session == "-":
|
|
144
|
+
lines.append(
|
|
145
|
+
"You have no parent session: skip only the `tell --parent` mail command and "
|
|
146
|
+
"end your final message with the one-line result instead. The self-close "
|
|
147
|
+
'command (`~/.ftown/ftown-sessions remove "$FTOWN_SESSION_ID"`) is still '
|
|
148
|
+
"MANDATORY and must be the very last command you run."
|
|
149
|
+
)
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def spawn_worker(
|
|
154
|
+
*, cfg: FactoryConfig, stage: StageCfg, ticket_id: int, briefing: str, repo_root: Path
|
|
155
|
+
) -> str | None:
|
|
156
|
+
"""Fire an ftown worker session. Returns the new session id ("" if the CLI output
|
|
157
|
+
could not be parsed) on success, None on failure; never retries in a tick."""
|
|
158
|
+
cmd: list[str] = [FTOWN_CLI, "create", "--shell", stage.harness]
|
|
159
|
+
if stage.model:
|
|
160
|
+
cmd += ["--model", stage.model]
|
|
161
|
+
cmd += ["--workdir", str(repo_root), "--name", f"{cfg.project}-t{ticket_id}-{stage.name}"]
|
|
162
|
+
if cfg.operator_session != "-":
|
|
163
|
+
cmd += ["--parent-id", cfg.operator_session]
|
|
164
|
+
cmd += ["--prompt", briefing]
|
|
165
|
+
try:
|
|
166
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
167
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
168
|
+
detail = getattr(exc, "stderr", "") or str(exc)
|
|
169
|
+
print(f"spawn failed for ticket {ticket_id} stage {stage.name}: {detail}", file=sys.stderr)
|
|
170
|
+
return None
|
|
171
|
+
# `create` prints JSON by default: {"session": {"id": "...", ...}}.
|
|
172
|
+
try:
|
|
173
|
+
session_id = str(json.loads(proc.stdout)["session"]["id"])
|
|
174
|
+
except (ValueError, KeyError, TypeError):
|
|
175
|
+
print(f"spawn ok but no session id parsed for ticket {ticket_id}", file=sys.stderr)
|
|
176
|
+
return ""
|
|
177
|
+
return session_id
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_workers(repo_root: Path) -> dict[str, dict]:
|
|
181
|
+
"""worker_id -> {session_id, ticket_id, epoch}. Missing/corrupt file -> empty map."""
|
|
182
|
+
try:
|
|
183
|
+
data = json.loads((repo_root / WORKERS_FILE).read_text())
|
|
184
|
+
except (OSError, ValueError):
|
|
185
|
+
return {}
|
|
186
|
+
return {str(k): v for k, v in data.items() if isinstance(v, dict)} if isinstance(data, dict) else {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def save_workers(repo_root: Path, workers: dict[str, dict]) -> None:
|
|
190
|
+
path = repo_root / WORKERS_FILE
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
path.write_text(json.dumps(workers, indent=2, sort_keys=True) + "\n")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def ftown_remove(session_id: str) -> None:
|
|
196
|
+
try:
|
|
197
|
+
subprocess.run([FTOWN_CLI, "remove", session_id], capture_output=True, text=True, check=True)
|
|
198
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
199
|
+
detail = getattr(exc, "stderr", "") or str(exc)
|
|
200
|
+
print(f"remove failed for session {session_id}: {detail}", file=sys.stderr)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def ftown_tell(session_id: str, message: str) -> None:
|
|
204
|
+
cmd = [FTOWN_CLI, "tell", session_id, "--type", "task", message]
|
|
205
|
+
try:
|
|
206
|
+
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
207
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
208
|
+
detail = getattr(exc, "stderr", "") or str(exc)
|
|
209
|
+
print(f"tell failed for session {session_id}: {detail}", file=sys.stderr)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def run_scheduler(fts: FTS) -> tuple[int, int]:
|
|
213
|
+
"""Drain the scheduler up to 5 rounds. Returns (requeued, promoted)."""
|
|
214
|
+
requeued = 0
|
|
215
|
+
promoted = 0
|
|
216
|
+
for _ in range(5):
|
|
217
|
+
report = fts.tick()
|
|
218
|
+
requeued += report.claims_expired
|
|
219
|
+
promoted += report.tickets_unblocked
|
|
220
|
+
if not report.more_pending:
|
|
221
|
+
break
|
|
222
|
+
return requeued, promoted
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def sweep_stragglers(fts: FTS, cfg: FactoryConfig) -> int:
|
|
226
|
+
"""Dead-letter every non-terminal ticket older than max_ticket_age_h. Runs in dry-run too."""
|
|
227
|
+
now_ms = int(time.time() * 1000)
|
|
228
|
+
max_age_ms = cfg.limits.max_ticket_age_h * 3_600_000
|
|
229
|
+
deadlettered = 0
|
|
230
|
+
for node in fts.dag().nodes:
|
|
231
|
+
if node.status in TERMINAL_STATUSES:
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
ticket = fts.get_ticket(node.id)
|
|
235
|
+
except FTSError as exc:
|
|
236
|
+
print(f"straggler read failed for ticket {node.id}: {exc}", file=sys.stderr)
|
|
237
|
+
continue
|
|
238
|
+
if now_ms - ticket.created_at_ms <= max_age_ms:
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
fts.dead_letter(ticket.id, actor=DEAD_LETTER_ACTOR, reason="max_ticket_age exceeded")
|
|
242
|
+
deadlettered += 1
|
|
243
|
+
except FTSError as exc:
|
|
244
|
+
print(f"dead_letter failed for ticket {ticket.id}: {exc}", file=sys.stderr)
|
|
245
|
+
return deadlettered
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def zero_progress_expiries(fts: FTS, ticket_id: int) -> int:
|
|
249
|
+
"""Count claim expiries where the worker never started (claimed -> queued).
|
|
250
|
+
|
|
251
|
+
fticket's history does not record renew_count; an expiry annotated with
|
|
252
|
+
from_status=claimed means the worker never even called start() — the closest
|
|
253
|
+
observable to "renew_count == 0". One fts.get_history call per candidate.
|
|
254
|
+
"""
|
|
255
|
+
return sum(
|
|
256
|
+
1
|
|
257
|
+
for h in fts.get_history(ticket_id)
|
|
258
|
+
if h.kind is HistoryKind.ANNOTATION
|
|
259
|
+
and h.note == "claim_expired"
|
|
260
|
+
and h.from_status is Status.CLAIMED
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def capacity_spawn(
|
|
265
|
+
fts: FTS,
|
|
266
|
+
cfg: FactoryConfig,
|
|
267
|
+
workers: dict[str, dict],
|
|
268
|
+
*,
|
|
269
|
+
repo_root: Path,
|
|
270
|
+
db_path: Path,
|
|
271
|
+
dry_run: bool,
|
|
272
|
+
) -> tuple[list[int], int, int, int]:
|
|
273
|
+
"""Claim + spawn up to capacity, stages in yaml order.
|
|
274
|
+
|
|
275
|
+
Returns (spawned_ids, rejected_input, expiry_capped, active_total). Guards run just
|
|
276
|
+
after the claim (fts.claim picks the ticket, so preflight can't run earlier); a guard
|
|
277
|
+
hit dead-letters the freshly claimed ticket instead of spawning.
|
|
278
|
+
"""
|
|
279
|
+
depths = {d.stage: d for d in fts.board()}
|
|
280
|
+
first_stage = cfg.stages[0].name if cfg.stages else None
|
|
281
|
+
|
|
282
|
+
def active_of(name: str) -> int:
|
|
283
|
+
d = depths.get(name)
|
|
284
|
+
return (d.claimed + d.in_progress + d.blocked) if d is not None else 0
|
|
285
|
+
|
|
286
|
+
active_total = sum(active_of(s.name) for s in cfg.stages)
|
|
287
|
+
budget = cfg.limits.max_sessions - active_total
|
|
288
|
+
spawned: list[int] = []
|
|
289
|
+
rejected_input = 0
|
|
290
|
+
expiry_capped = 0
|
|
291
|
+
|
|
292
|
+
def guard_dead_letter(ticket_id: int, reason: str) -> None:
|
|
293
|
+
try:
|
|
294
|
+
fts.dead_letter(ticket_id, actor=DEAD_LETTER_ACTOR, reason=reason)
|
|
295
|
+
except FTSError as exc:
|
|
296
|
+
print(f"dead_letter failed for ticket {ticket_id}: {exc}", file=sys.stderr)
|
|
297
|
+
|
|
298
|
+
for stage in cfg.stages:
|
|
299
|
+
free = min(stage.max_workers - active_of(stage.name), budget)
|
|
300
|
+
if free <= 0:
|
|
301
|
+
continue
|
|
302
|
+
ttl = stage.claim_ttl_ms or cfg.limits.claim_ttl_ms
|
|
303
|
+
|
|
304
|
+
if dry_run:
|
|
305
|
+
depth = depths.get(stage.name)
|
|
306
|
+
queued = depth.queued if depth is not None else 0
|
|
307
|
+
would = min(free, queued)
|
|
308
|
+
if would > 0:
|
|
309
|
+
print(f"[dry-run] would claim up to {would} ticket(s) in stage {stage.name}")
|
|
310
|
+
budget -= would
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
for _ in range(free):
|
|
314
|
+
worker_id = f"{cfg.project}-{stage.name}-{uuid.uuid4().hex[:8]}"
|
|
315
|
+
try:
|
|
316
|
+
result = fts.claim(stage.name, worker_id, ttl_ms=ttl)
|
|
317
|
+
except FTSError as exc:
|
|
318
|
+
print(f"claim failed for stage {stage.name}: {exc}", file=sys.stderr)
|
|
319
|
+
break
|
|
320
|
+
if result is None:
|
|
321
|
+
break
|
|
322
|
+
ticket = result.ticket
|
|
323
|
+
ticket_dir = (repo_root / ticket.folder_path).resolve()
|
|
324
|
+
if zero_progress_expiries(fts, ticket.id) >= ZERO_PROGRESS_LIMIT:
|
|
325
|
+
guard_dead_letter(
|
|
326
|
+
ticket.id, "3+ claims expired with no progress — inspect worker logs"
|
|
327
|
+
)
|
|
328
|
+
expiry_capped += 1
|
|
329
|
+
continue
|
|
330
|
+
if stage.name == first_stage:
|
|
331
|
+
request_md = ticket_dir / "request.md"
|
|
332
|
+
try:
|
|
333
|
+
seeded = request_md.is_file() and bool(request_md.read_text().strip())
|
|
334
|
+
except OSError:
|
|
335
|
+
seeded = False
|
|
336
|
+
if not seeded:
|
|
337
|
+
guard_dead_letter(
|
|
338
|
+
ticket.id, "missing request.md — seed the ticket folder and revive"
|
|
339
|
+
)
|
|
340
|
+
rejected_input += 1
|
|
341
|
+
continue
|
|
342
|
+
briefing = compose_briefing(
|
|
343
|
+
ticket_id=ticket.id,
|
|
344
|
+
stage=stage,
|
|
345
|
+
db_path=db_path,
|
|
346
|
+
ticket_dir=ticket_dir,
|
|
347
|
+
repo_root=repo_root,
|
|
348
|
+
epoch=result.epoch,
|
|
349
|
+
worker_id=worker_id,
|
|
350
|
+
operator_session=cfg.operator_session,
|
|
351
|
+
)
|
|
352
|
+
session_id = spawn_worker(
|
|
353
|
+
cfg=cfg, stage=stage, ticket_id=ticket.id, briefing=briefing, repo_root=repo_root
|
|
354
|
+
)
|
|
355
|
+
if session_id is not None:
|
|
356
|
+
spawned.append(ticket.id)
|
|
357
|
+
if session_id:
|
|
358
|
+
workers[worker_id] = {
|
|
359
|
+
"session_id": session_id,
|
|
360
|
+
"ticket_id": ticket.id,
|
|
361
|
+
"epoch": result.epoch,
|
|
362
|
+
}
|
|
363
|
+
budget -= 1
|
|
364
|
+
if budget <= 0:
|
|
365
|
+
break
|
|
366
|
+
if budget <= 0:
|
|
367
|
+
break
|
|
368
|
+
return spawned, rejected_input, expiry_capped, active_total
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def reap_workers(workers: dict[str, dict], ev) -> None:
|
|
372
|
+
"""Apply one event to the worker map: reap (ftown remove) or prune the mapping.
|
|
373
|
+
|
|
374
|
+
Expiry events carry the exact worker_id; the other lifecycle events match by
|
|
375
|
+
ticket_id, guarded by claim epoch (ev.cursor > entry epoch) so a stale event can
|
|
376
|
+
never evict a session spawned later in the same or a following tick.
|
|
377
|
+
"""
|
|
378
|
+
if ev.type is EventType.TICKET_CLAIM_EXPIRED:
|
|
379
|
+
wid = str(ev.payload.get("worker_id", ""))
|
|
380
|
+
entry = workers.pop(wid, None)
|
|
381
|
+
if entry and entry.get("session_id"):
|
|
382
|
+
ftown_remove(str(entry["session_id"]))
|
|
383
|
+
return
|
|
384
|
+
if ev.type in REAP_EVENTS:
|
|
385
|
+
stale = [
|
|
386
|
+
wid
|
|
387
|
+
for wid, e in workers.items()
|
|
388
|
+
if e.get("ticket_id") == ev.ticket_id and ev.cursor > int(e.get("epoch", 0))
|
|
389
|
+
]
|
|
390
|
+
for wid in stale:
|
|
391
|
+
entry = workers.pop(wid)
|
|
392
|
+
if entry.get("session_id"):
|
|
393
|
+
ftown_remove(str(entry["session_id"]))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def bridge_events(
|
|
397
|
+
fts: FTS, cfg: FactoryConfig, workers: dict[str, dict], *, repo_root: Path, dry_run: bool
|
|
398
|
+
) -> int:
|
|
399
|
+
"""Forward dead-letter/orphan events to the operator, reap/prune worker sessions on
|
|
400
|
+
lifecycle events; advance the cursor past all events."""
|
|
401
|
+
cursor_path = repo_root / CURSOR_FILE
|
|
402
|
+
try:
|
|
403
|
+
cursor = int(cursor_path.read_text().strip())
|
|
404
|
+
except (OSError, ValueError):
|
|
405
|
+
cursor = 0
|
|
406
|
+
events = fts.poll_events(after_cursor=cursor)
|
|
407
|
+
for ev in events:
|
|
408
|
+
if not dry_run:
|
|
409
|
+
reap_workers(workers, ev)
|
|
410
|
+
if ev.type not in FORWARD_EVENTS:
|
|
411
|
+
continue
|
|
412
|
+
msg = f"[{cfg.project}] {ev.type} ticket={ev.ticket_id} (cursor {ev.cursor})"
|
|
413
|
+
if dry_run or cfg.operator_session == "-":
|
|
414
|
+
print(f"[event] {msg}")
|
|
415
|
+
else:
|
|
416
|
+
ftown_tell(cfg.operator_session, msg)
|
|
417
|
+
if events and not dry_run:
|
|
418
|
+
cursor_path.write_text(str(events[-1].cursor))
|
|
419
|
+
return len(events)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def run_tick(repo_root: Path, *, dry_run: bool) -> int:
|
|
423
|
+
yaml_path = repo_root / "factory" / "factory.yaml"
|
|
424
|
+
if not yaml_path.is_file():
|
|
425
|
+
print(f"error: {yaml_path} not found (run from the project repo root)", file=sys.stderr)
|
|
426
|
+
return 2
|
|
427
|
+
db_path = (repo_root / ".ffactory" / "factory.db").resolve()
|
|
428
|
+
if not db_path.is_file():
|
|
429
|
+
print(f"error: {db_path} not found (run `factory init` first)", file=sys.stderr)
|
|
430
|
+
return 2
|
|
431
|
+
|
|
432
|
+
cfg = load_config(yaml_path)
|
|
433
|
+
workers = load_workers(repo_root)
|
|
434
|
+
fts = FTS(db_path)
|
|
435
|
+
try:
|
|
436
|
+
requeued, promoted = run_scheduler(fts)
|
|
437
|
+
deadlettered = sweep_stragglers(fts, cfg)
|
|
438
|
+
spawned, rejected_input, expiry_capped, active_total = capacity_spawn(
|
|
439
|
+
fts, cfg, workers, repo_root=repo_root, db_path=db_path, dry_run=dry_run
|
|
440
|
+
)
|
|
441
|
+
events = bridge_events(fts, cfg, workers, repo_root=repo_root, dry_run=dry_run)
|
|
442
|
+
finally:
|
|
443
|
+
fts.close()
|
|
444
|
+
if not dry_run:
|
|
445
|
+
save_workers(repo_root, workers)
|
|
446
|
+
|
|
447
|
+
spawned_str = ",".join(str(i) for i in spawned) if spawned else "0"
|
|
448
|
+
print(
|
|
449
|
+
f"tick: requeued={requeued} promoted={promoted} spawned={spawned_str} "
|
|
450
|
+
f"rejected_input={rejected_input} expiry_capped={expiry_capped} "
|
|
451
|
+
f"deadlettered={deadlettered} events={events} "
|
|
452
|
+
f"active={active_total}/{cfg.limits.max_sessions}"
|
|
453
|
+
)
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def main() -> int:
|
|
458
|
+
parser = argparse.ArgumentParser(description="No-LLM factory dispatcher (one tick).")
|
|
459
|
+
parser.add_argument("--dry-run", action="store_true", help="Never claim or spawn; print plan.")
|
|
460
|
+
args = parser.parse_args()
|
|
461
|
+
repo_root = Path.cwd().resolve()
|
|
462
|
+
try:
|
|
463
|
+
return run_tick(repo_root, dry_run=args.dry_run)
|
|
464
|
+
except SystemExit:
|
|
465
|
+
raise
|
|
466
|
+
except Exception:
|
|
467
|
+
traceback.print_exc()
|
|
468
|
+
return 1
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
if __name__ == "__main__":
|
|
472
|
+
sys.exit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Factory definition — checked into the project repo. Runtime state lives in
|
|
2
|
+
# .ffactory/ (gitignored). Copied from ffactory/factory-template by `factory init`.
|
|
3
|
+
|
|
4
|
+
project: my-project # used to namespace ftown loops and worker names
|
|
5
|
+
operator_session: "-" # ftown session id for digests/worker mail; "-" = none
|
|
6
|
+
|
|
7
|
+
limits:
|
|
8
|
+
max_sessions: 6 # global concurrent worker cap for this factory
|
|
9
|
+
bounce_limit: 3 # rejections before auto dead-letter (fts config)
|
|
10
|
+
claim_ttl_ms: 1800000 # default claim TTL (30m); stages may override
|
|
11
|
+
max_ticket_age_h: 48 # dispatcher dead-letters tickets older than this
|
|
12
|
+
dispatch_every: 30s # dispatcher loop interval (skip-overlap)
|
|
13
|
+
|
|
14
|
+
# Harness per stage: claude | codex | cursor | zai | kimi | deepseek | fireworks
|
|
15
|
+
# next: where advance() sends the ticket on success (- = terminal)
|
|
16
|
+
# bounce: where reject() sends it (- = rejection not allowed)
|
|
17
|
+
stages:
|
|
18
|
+
- { name: groom, harness: claude, model: sonnet, max_workers: 1, next: design, bounce: "-" }
|
|
19
|
+
- { name: design, harness: claude, model: opus, max_workers: 1, next: implement, bounce: groom }
|
|
20
|
+
- { name: implement, harness: codex, max_workers: 3, next: review, bounce: design, claim_ttl_ms: 2700000 }
|
|
21
|
+
- { name: review, harness: claude, model: opus, max_workers: 2, next: qa, bounce: implement }
|
|
22
|
+
- { name: qa, harness: claude, model: sonnet, max_workers: 1, next: pr, bounce: implement }
|
|
23
|
+
- { name: pr, harness: claude, model: sonnet, max_workers: 1, next: "-", bounce: implement, allow_push: true }
|
|
24
|
+
|
|
25
|
+
resources: # shared surfaces workers must lease before touching
|
|
26
|
+
- { name: staging, mode_policy: exclusive_only }
|
|
27
|
+
|
|
28
|
+
triage:
|
|
29
|
+
every: 10m # interval sweep; preflight skips (no session) when idle
|
|
30
|
+
harness: claude
|
|
31
|
+
model: sonnet
|
|
32
|
+
|
|
33
|
+
digest:
|
|
34
|
+
cron: "0 9 * * *" # daily shift report mailed to operator_session
|
|
35
|
+
harness: claude
|
|
36
|
+
model: sonnet
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Worker Protocol (read this before your stage skill — it is binding)
|
|
2
|
+
|
|
3
|
+
You are an autonomous worker session inside a software factory. You were spawned to do
|
|
4
|
+
ONE stage of ONE ticket, then exit. You do not choose what to work on; the dispatcher
|
|
5
|
+
already claimed the ticket for you.
|
|
6
|
+
|
|
7
|
+
## Your briefing variables
|
|
8
|
+
|
|
9
|
+
Your spawn prompt defines these values. Refer to them exactly as named:
|
|
10
|
+
|
|
11
|
+
- `TICKET_ID` — the ticket you own.
|
|
12
|
+
- `STAGE` — the stage you are executing (matches your skill file).
|
|
13
|
+
- `NEXT_STAGE` — where the ticket goes if you succeed (`-` means terminal).
|
|
14
|
+
- `BOUNCE_STAGE` — where the ticket goes if you reject it (`-` means rejection not allowed at this stage).
|
|
15
|
+
- `FTS_DB` — path to the factory database. Every `fts` command needs `--db "$FTS_DB"`.
|
|
16
|
+
- `TICKET_DIR` — the ticket's artifact folder. NEVER move or rename it.
|
|
17
|
+
- `REPO_ROOT` — the project repository root.
|
|
18
|
+
- `EPOCH` — your claim fence token. Pass it to every `--epoch` flag shown below.
|
|
19
|
+
- `WORKER_ID` — your identity, exactly as given here (the dispatcher derives it before
|
|
20
|
+
your session exists; use it verbatim, never substitute `$FTOWN_SESSION_ID`).
|
|
21
|
+
($FTOWN_SESSION_ID is your session id — used only for the final self-close.)
|
|
22
|
+
|
|
23
|
+
## The fts commands you are allowed to use
|
|
24
|
+
|
|
25
|
+
Copy these shapes exactly; do not invent flags:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
fts show --db "$FTS_DB" "$TICKET_ID" --json # read ticket + history
|
|
29
|
+
fts start --db "$FTS_DB" --ticket "$TICKET_ID" --worker "$WORKER_ID" --epoch "$EPOCH"
|
|
30
|
+
fts renew --db "$FTS_DB" --ticket "$TICKET_ID" --worker "$WORKER_ID" --epoch "$EPOCH"
|
|
31
|
+
fts acquire --db "$FTS_DB" --resource <name> --ticket "$TICKET_ID" --worker "$WORKER_ID" --mode exclusive
|
|
32
|
+
fts release --db "$FTS_DB" --ticket "$TICKET_ID" --worker "$WORKER_ID" --resource <name> --epoch "$EPOCH"
|
|
33
|
+
fts complete --db "$FTS_DB" --ticket "$TICKET_ID" --worker "$WORKER_ID" --epoch "$EPOCH" --note "<one line>"
|
|
34
|
+
fts advance --db "$FTS_DB" --ticket "$TICKET_ID" --worker "$WORKER_ID" --to-stage "$NEXT_STAGE" --note "<one line>"
|
|
35
|
+
fts reject --db "$FTS_DB" --ticket "$TICKET_ID" --worker "$WORKER_ID" --epoch "$EPOCH" \
|
|
36
|
+
--reason "<structured feedback, see your skill>" --to-stage "$BOUNCE_STAGE"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You may NOT use any other fts subcommand — unless your stage skill explicitly grants one
|
|
40
|
+
(e.g. groom may `create`/`add-dep` to split work). Never open or modify the database file
|
|
41
|
+
directly.
|
|
42
|
+
|
|
43
|
+
## Lifecycle (always the same)
|
|
44
|
+
|
|
45
|
+
1. `fts start` FIRST, before any work. If it fails, STOP and exit — you do not own this ticket.
|
|
46
|
+
2. Read context: `fts show --json`, then read `TICKET_DIR` artifacts from earlier stages.
|
|
47
|
+
3. Do the work your stage skill prescribes.
|
|
48
|
+
4. **Heartbeat:** run `fts renew` after EVERY substantial step (any test run, file written,
|
|
49
|
+
long command). If renew ever fails (`ClaimExpired`, `ClaimFenced`, `NotClaimOwner`):
|
|
50
|
+
STOP IMMEDIATELY. Do not write anything more, do not "finish up". Another worker owns
|
|
51
|
+
the ticket now. Mail your parent that you were fenced, then self-close (step 7) and exit.
|
|
52
|
+
5. Run your skill's GATE checklist. Every box must be checked with evidence, not assumed.
|
|
53
|
+
6. Outcome — exactly one of:
|
|
54
|
+
- PASS: `fts complete` then (if `NEXT_STAGE` != `-`) `fts advance`.
|
|
55
|
+
- FAIL with actionable feedback and `BOUNCE_STAGE` != `-`: `fts reject` with the
|
|
56
|
+
structured reason your skill defines.
|
|
57
|
+
- STUCK (missing input, broken environment, contradictory requirements): do NOT
|
|
58
|
+
complete, do NOT reject. Mail your parent the exact blocker and exit; your claim
|
|
59
|
+
will expire and the ticket re-queues. Then self-close (step 7) — never leave your
|
|
60
|
+
session idling.
|
|
61
|
+
7. Mail a result summary, then self-close. This applies to EVERY outcome — PASS, REJECT,
|
|
62
|
+
and STUCK alike — and the self-close command must be the very last command you run;
|
|
63
|
+
nothing after it executes:
|
|
64
|
+
```bash
|
|
65
|
+
~/.ftown/ftown-sessions tell --parent --type result \
|
|
66
|
+
"ticket $TICKET_ID $STAGE: <PASS|REJECTED|STUCK> — <one sentence>"
|
|
67
|
+
~/.ftown/ftown-sessions remove "$FTOWN_SESSION_ID" # self-close; your session ends here
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Hard rules
|
|
71
|
+
|
|
72
|
+
- Write ONLY inside `TICKET_DIR` (and, for the implement stage, your assigned git
|
|
73
|
+
worktree). Never write to other tickets' folders, never touch `.ffactory/factory.db*`.
|
|
74
|
+
- Artifacts accrete: never delete or rewrite an earlier stage's artifact. If you disagree
|
|
75
|
+
with it, that is a `reject` with reasons, not an edit.
|
|
76
|
+
- Never move `TICKET_DIR` (paths are stable for the ticket's lifetime).
|
|
77
|
+
- Never `git push`, create PRs, or call external services unless your stage skill
|
|
78
|
+
explicitly instructs it.
|
|
79
|
+
- Never add credentials to code or artifacts.
|
|
80
|
+
- If you touch a shared external surface (staging env, test database), `acquire` a lease
|
|
81
|
+
first and `release` when done. Acquire multiple resources in alphabetical order.
|
|
82
|
+
- One ticket, one stage, one session. When your outcome is recorded: exit. Do not pick up
|
|
83
|
+
other work, do not "improve" unrelated code.
|
|
84
|
+
- If the same error defeats you twice, your assumption is wrong: re-read the artifacts
|
|
85
|
+
and this protocol instead of retrying a third time; if still stuck, use the STUCK path.
|