ftown-bridge 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/agent-commands.d.ts +3 -17
  2. package/dist/agent-commands.js +8 -90
  3. package/dist/agent-commands.js.map +1 -1
  4. package/dist/bridge-auth.d.ts +21 -0
  5. package/dist/bridge-auth.js +48 -0
  6. package/dist/bridge-auth.js.map +1 -0
  7. package/dist/centrifugo-client.js +39 -0
  8. package/dist/centrifugo-client.js.map +1 -1
  9. package/dist/command-rpc.d.ts +15 -0
  10. package/dist/command-rpc.js +249 -0
  11. package/dist/command-rpc.js.map +1 -0
  12. package/dist/create-ftown-session.d.ts +43 -0
  13. package/dist/create-ftown-session.js +115 -9
  14. package/dist/create-ftown-session.js.map +1 -1
  15. package/dist/ftown-sessions-cli.js +74 -4
  16. package/dist/ftown-sessions-cli.js.map +1 -1
  17. package/dist/harness-cli.js +1 -13
  18. package/dist/harness-cli.js.map +1 -1
  19. package/dist/harness-installer.js +4 -2
  20. package/dist/harness-installer.js.map +1 -1
  21. package/dist/harness-registry.d.ts +189 -0
  22. package/dist/harness-registry.js +218 -0
  23. package/dist/harness-registry.js.map +1 -0
  24. package/dist/index.js +72 -721
  25. package/dist/index.js.map +1 -1
  26. package/dist/install-ftown-cli.js +12 -3
  27. package/dist/install-ftown-cli.js.map +1 -1
  28. package/dist/local-api-server.d.ts +14 -13
  29. package/dist/local-api-server.js +180 -424
  30. package/dist/local-api-server.js.map +1 -1
  31. package/dist/loop-controller.d.ts +87 -0
  32. package/dist/loop-controller.js +133 -0
  33. package/dist/loop-controller.js.map +1 -0
  34. package/dist/loop-run-store.js +2 -8
  35. package/dist/loop-run-store.js.map +1 -1
  36. package/dist/loop-validation.d.ts +1 -0
  37. package/dist/loop-validation.js +3 -8
  38. package/dist/loop-validation.js.map +1 -1
  39. package/dist/mail-delivery.d.ts +114 -0
  40. package/dist/mail-delivery.js +315 -0
  41. package/dist/mail-delivery.js.map +1 -0
  42. package/dist/session-controller.d.ts +107 -0
  43. package/dist/session-controller.js +141 -0
  44. package/dist/session-controller.js.map +1 -0
  45. package/dist/session-ids.d.ts +19 -0
  46. package/dist/session-ids.js +72 -0
  47. package/dist/session-ids.js.map +1 -0
  48. package/dist/session-resurrection.d.ts +24 -0
  49. package/dist/session-resurrection.js +128 -0
  50. package/dist/session-resurrection.js.map +1 -1
  51. package/dist/spawn-stagger.d.ts +22 -0
  52. package/dist/spawn-stagger.js +34 -0
  53. package/dist/spawn-stagger.js.map +1 -0
  54. package/dist/terminal-pump.d.ts +61 -0
  55. package/dist/terminal-pump.js +193 -0
  56. package/dist/terminal-pump.js.map +1 -0
  57. package/dist/types.d.ts +29 -4
  58. package/dist/usage-collector.d.ts +42 -0
  59. package/dist/usage-collector.js +317 -0
  60. package/dist/usage-collector.js.map +1 -0
  61. package/dist/wire-types.d.ts +124 -0
  62. package/dist/wire-types.js +28 -0
  63. package/dist/wire-types.js.map +1 -0
  64. package/dist/workflow-runner-cli.js +2 -1
  65. package/dist/workflow-runner-cli.js.map +1 -1
  66. package/dist/workflow-runner.d.ts +2 -1
  67. package/dist/workflow-runner.js.map +1 -1
  68. package/package.json +1 -1
  69. package/skills/factory/SKILL.md +167 -0
  70. package/skills/factory/factory-template/bin/dispatch.py +472 -0
  71. package/skills/factory/factory-template/factory.yaml +36 -0
  72. package/skills/factory/factory-template/skills/_protocol.md +85 -0
  73. package/skills/factory/factory-template/skills/design.md +259 -0
  74. package/skills/factory/factory-template/skills/digest.md +78 -0
  75. package/skills/factory/factory-template/skills/groom.md +243 -0
  76. package/skills/factory/factory-template/skills/implement.md +316 -0
  77. package/skills/factory/factory-template/skills/pr.md +220 -0
  78. package/skills/factory/factory-template/skills/qa.md +226 -0
  79. package/skills/factory/factory-template/skills/review.md +231 -0
  80. 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.