@giamme/forge 0.0.0-stage → 0.9.0

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 (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,659 @@
1
+ """Forge-owned bounded task trees using the pinned Fractal backend and ledger.
2
+
3
+ The control repository holds Fractal initialization artifacts. Product candidates
4
+ live in separate snapshot repositories and are imported only with drift checks.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import math
13
+ import os
14
+ from pathlib import Path, PurePosixPath
15
+ import shlex
16
+ import shutil
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ import time
22
+ import uuid
23
+
24
+ from . import SCRIPTS, artifact, command, identifier, lock, managed_run, read_json, runtime_python, write_json
25
+
26
+ PROTOCOL = '''
27
+ Forge owns coordination and independent QA. Implement and run requested checks.
28
+ You can request bounded nested children by ending your response with one line:
29
+ FORGE_FRACTAL: {"children":[{"id":"name","goal":"complete instructions","difficulty":"low|medium|high","paths":["relative/path"],"deps":[]}]}
30
+ Child IDs are unique in this task. Dependencies name earlier children or children
31
+ in the same batch. Children must stay inside your owned paths. Return immediately
32
+ after requesting children; Forge releases your invocation while they run. Their
33
+ combined changes and results will be available on your next fresh invocation.
34
+ When implementation and requested verification are finished, end with:
35
+ FORGE_FRACTAL: {"done":true}
36
+ Do not run Fractal control commands directly. Do not change control metadata.
37
+ '''
38
+
39
+
40
+ def path_scope(value: str) -> str:
41
+ p = PurePosixPath(value)
42
+ if p.is_absolute() or '..' in p.parts or '\\' in value or not value:
43
+ raise ValueError('Ownership must use relative repository paths')
44
+ if p.parts and p.parts[0] in ('.git', '.forge', '.fractal'):
45
+ raise ValueError('Metadata cannot be owned by an implementation node')
46
+ return str(p)
47
+
48
+
49
+ def contains(parent: str, child: str) -> bool:
50
+ return parent == '.' or child == parent or child.startswith(parent.rstrip('/') + '/')
51
+
52
+
53
+ def overlap(left: list[str], right: list[str]) -> bool:
54
+ return any(contains(a, b) or contains(b, a) for a in left for b in right)
55
+
56
+
57
+ def event(root: Path, kind: str, **data) -> None:
58
+ with lock(root / 'events.lock'):
59
+ with (root / 'events.jsonl').open('a') as stream:
60
+ stream.write(json.dumps(dict(at=time.time(), kind=kind, **data)) + '\n')
61
+
62
+
63
+ def terminate(process: subprocess.Popen) -> None:
64
+ # Every managed invocation is its own session; never signal an arbitrary PID.
65
+ if process.poll() is not None:
66
+ return
67
+ with contextlib.suppress(ProcessLookupError):
68
+ os.killpg(process.pid, signal.SIGTERM)
69
+ try:
70
+ process.wait(timeout=2)
71
+ except subprocess.TimeoutExpired:
72
+ with contextlib.suppress(ProcessLookupError):
73
+ os.killpg(process.pid, signal.SIGKILL)
74
+ process.wait(timeout=2)
75
+
76
+
77
+ def process_identity(pid: int) -> str:
78
+ return subprocess.run(['ps', '-p', str(pid), '-o', 'lstart=,comm='], capture_output=True, text=True).stdout.strip()
79
+
80
+
81
+ def reap_recorded(task: Path) -> None:
82
+ for path in task.glob('nodes/*/steps/*/process.json'):
83
+ recorded = read_json(path)
84
+ pid = recorded['pid']
85
+ if recorded['identity'] and process_identity(pid) == recorded['identity']:
86
+ with contextlib.suppress(ProcessLookupError):
87
+ os.killpg(pid, signal.SIGKILL)
88
+
89
+
90
+ @contextlib.contextmanager
91
+ def slot(run: Path, check):
92
+ capacity = read_json(run / 'run.json')['limits']['concurrency']
93
+ while True:
94
+ check()
95
+ for i in range(capacity):
96
+ manager = lock(run / 'slots' / str(i), blocking=False)
97
+ try:
98
+ manager.__enter__()
99
+ except BlockingIOError:
100
+ continue
101
+ try:
102
+ yield
103
+ finally:
104
+ manager.__exit__(None, None, None)
105
+ return
106
+ time.sleep(.1)
107
+
108
+
109
+ def bridge(step: Path, prompt: Path, model: str) -> int:
110
+ config = read_json(step / 'request.json')
111
+ role = config.get('role', 'dwarf')
112
+ if role not in ('dwarf', 'planner'):
113
+ raise ValueError('Unsupported Fractal bridge role')
114
+ argv = ['/bin/bash', str(SCRIPTS / 'forge-dispatch.sh'), role, model,
115
+ '--repo', config['workspace'], '--run-dir', str(step), '--prompt-file', str(prompt),
116
+ '--timeout', str(max(1, math.ceil(config['remaining']))), '--output', 'summary']
117
+ if config['yolo'] and role == 'dwarf':
118
+ argv.append('--yolo')
119
+ # Stay in the bridge session so its entire descendant group is reapable.
120
+ with (step / 'dispatch.out').open('w') as output:
121
+ result = subprocess.run(argv, stdout=output, stderr=subprocess.STDOUT)
122
+ last = step / (role + '.last')
123
+ text = last.read_text(errors='replace') if last.exists() else 'Dispatcher failed; inspect dispatch.out'
124
+ cost = None
125
+ if model.endswith(':claude') and (step / (role + '.log')).exists():
126
+ for line in (step / (role + '.log')).read_text(errors='replace').splitlines():
127
+ try:
128
+ frame = json.loads(line)
129
+ except ValueError:
130
+ continue
131
+ value = frame.get('total_cost_usd') if isinstance(frame, dict) and frame.get('type') == 'result' else None
132
+ if type(value) in (float, int) and math.isfinite(value) and value >= 0:
133
+ cost = value
134
+ print(json.dumps(dict(forge_event='result', model=model, text=text, cost=cost, exit_code=result.returncode)), flush=True)
135
+ return result.returncode
136
+
137
+
138
+ class Halt(Exception):
139
+ pass
140
+
141
+
142
+ class Tree:
143
+ def __init__(self, run: Path, task: Path) -> None:
144
+ self.run, self.task = run, task
145
+ self.config = read_json(run / 'run.json')
146
+ self.request = read_json(task / 'request.json')
147
+ self.limits = self.config['limits']
148
+ self.guard = threading.RLock()
149
+ self.clock = time.monotonic()
150
+ self.elapsed = read_json(task / 'state.json').get('elapsed', 0)
151
+ self.cancel = threading.Event()
152
+
153
+ def nodes(self) -> dict:
154
+ return {p.parent.name: read_json(p) for p in sorted((self.task / 'nodes').glob('*/node.json'))}
155
+
156
+ def update(self, node: dict, **values) -> None:
157
+ with self.guard:
158
+ node.update(values)
159
+ write_json(self.task / 'nodes' / node['id'] / 'node.json', node)
160
+
161
+ def control(self, node: dict | None = None) -> str:
162
+ names = ['all', self.task.name]
163
+ if node:
164
+ names += [self.task.name + ':' + name for name in node['ancestors'] + [node['id']]]
165
+ actions = []
166
+ for name in names:
167
+ p = self.run / 'controls' / (name + '.json')
168
+ if p.exists():
169
+ actions.append(read_json(p)['action'])
170
+ if 'stop' in actions:
171
+ return 'stop'
172
+ return 'pause' if 'pause' in actions else 'resume'
173
+
174
+ def check(self, node: dict | None = None) -> None:
175
+ with self.guard:
176
+ now = time.monotonic()
177
+ paused = any(self.control(n) == 'pause' for n in self.nodes().values())
178
+ if not paused:
179
+ self.elapsed += now - self.clock
180
+ self.clock = now
181
+ state = read_json(self.task / 'state.json')
182
+ state['elapsed'] = self.elapsed
183
+ state['status'] = 'paused' if paused else 'active'
184
+ write_json(self.task / 'state.json', state)
185
+ if self.cancel.is_set():
186
+ raise Halt('interrupted')
187
+ if self.control(node) == 'stop':
188
+ raise Halt('stopped')
189
+ if self.elapsed >= self.limits['deadline']:
190
+ raise Halt('timeout')
191
+
192
+ def pause_gate(self, node: dict) -> None:
193
+ while self.control(node) == 'pause':
194
+ self.update(node, status='paused')
195
+ self.check(node)
196
+ time.sleep(.2)
197
+ self.check(node)
198
+
199
+ def initialize(self) -> None:
200
+ from fractal.core.agent import register
201
+ from fractal.core.node import Node
202
+ from .bridge import ForgeAgent
203
+ register('forge-bridge', ForgeAgent)
204
+ control = self.task / 'control'
205
+ if not control.exists():
206
+ control.mkdir()
207
+ command(['git', 'init', '-qb', 'forge-root', control])
208
+ command(['git', '-c', 'user.name=forge', '-c', 'user.email=forge@local',
209
+ 'commit', '--allow-empty', '-qm', 'Fractal control baseline'], cwd=control)
210
+ root = Node(control)
211
+ root.init(user=True, agent='forge-bridge')
212
+ hook = root.node_dir / 'agents.py'
213
+ hook.write_text('from forge_fractal.bridge import ForgeAgent\n__all__ = ["ForgeAgent"]\n')
214
+ command(['git', 'add', '-A'], cwd=control)
215
+ command(['git', '-c', 'user.name=forge', '-c', 'user.email=forge@local',
216
+ 'commit', '--allow-empty', '-qm', 'Fractal initialization artifacts'], cwd=control)
217
+ write_json(self.task / 'initialization.json', dict(repository=str(control), ledger=str(root.db.path),
218
+ tree=command(['git', 'rev-parse', 'HEAD^{tree}'], cwd=control).decode().strip()))
219
+ if not self.nodes():
220
+ self.create_node('implementation', None, self.request['prompt'], 'medium', self.request['paths'], [], self.request['model'])
221
+
222
+ def create_node(self, name: str, parent: dict | None, goal: str, difficulty: str,
223
+ paths: list[str], deps: list[str], model: str) -> dict:
224
+ from fractal.core.node import Node
225
+ from fractal.util.git import find_worktree
226
+ control = self.task / 'control'
227
+ owner = Node(control)
228
+ branch = (parent['branch'] if parent else owner.branch) + '.' + name
229
+ existing = find_worktree(repo_dir=control, branch=branch)
230
+ # An accepted admission can outlive a crash between upstream init and node.json.
231
+ if existing is None or not Node(existing).exists():
232
+ owner.init(name, path=('.worktrees/' + parent['branch']) if parent else None,
233
+ agent='forge-bridge', max_depth=self.limits['depth'],
234
+ max_children=self.limits['children'], max_descendants=self.limits['nodes'] - 1)
235
+ existing = find_worktree(repo_dir=control, branch=branch)
236
+ child = Node(existing)
237
+ node = dict(id=name, parent=parent['id'] if parent else None, goal=goal, difficulty=difficulty,
238
+ paths=paths, deps=deps, model=model, status='pending', iteration=0,
239
+ ancestors=parent['ancestors'] + [parent['id']] if parent else [],
240
+ control=str(child.worktree), branch=branch, cost=None, tokens=None, tools=None)
241
+ self.update(node)
242
+ event(self.task, 'node_created', node=name, difficulty=difficulty, model=model)
243
+ return node
244
+
245
+ def candidate(self, node: dict) -> Path:
246
+ directory = self.task / 'nodes' / node['id']
247
+ workspace = directory / 'product'
248
+ if not workspace.exists():
249
+ source = Path(self.request['repo']) if not node['parent'] else self.task / 'nodes' / node['parent'] / 'product'
250
+ base = artifact('forge_tree', source)
251
+ fingerprint = artifact('forge_fingerprint', source)
252
+ artifact('forge_review_snapshot', source, base, base, workspace)
253
+ if artifact('forge_fingerprint', source) != fingerprint:
254
+ raise Halt('source_drift')
255
+ self.update(node, base=base, source=str(source), fingerprint=fingerprint, workspace=str(workspace))
256
+ return workspace
257
+
258
+ def admit(self, parent: dict, requests: list) -> None:
259
+ with self.guard:
260
+ for child in self.validate_children(parent, requests):
261
+ self.create_node(parent=parent, **child)
262
+
263
+ def validate_children(self, parent: dict, requests: list) -> list[dict]:
264
+ from .decomposition import reserved_nodes
265
+ nodes = reserved_nodes(self.nodes())
266
+ if len(parent['ancestors']) >= self.limits['depth']:
267
+ raise ValueError('Nesting limit exceeded')
268
+ unsettled = [n for n in nodes.values() if n['parent'] == parent['id'] and n['status'] != 'completed']
269
+ if len(requests) + len(unsettled) > self.limits['children'] or len(nodes) + len(requests) > self.limits['nodes']:
270
+ raise ValueError('Child or lifetime-node limit exceeded')
271
+ validated = []
272
+ names = set()
273
+ for request in requests:
274
+ name = identifier(request['id'])
275
+ if name.casefold() in {key.casefold() for key in nodes.keys() | names}:
276
+ raise ValueError('Child identifiers must be unique for the task lifetime')
277
+ names.add(name)
278
+ difficulty = request['difficulty']
279
+ if difficulty not in ('low', 'medium', 'high'):
280
+ raise ValueError('Invalid child difficulty')
281
+ paths = [path_scope(p) for p in request['paths']]
282
+ if not paths or not all(any(contains(p, c) for p in parent['paths']) for c in paths):
283
+ raise ValueError('Child ownership escapes parent scope')
284
+ goal = request['goal']
285
+ if not isinstance(goal, str) or not goal.strip():
286
+ raise ValueError('Child goal must be nonempty')
287
+ deps = [identifier(d) for d in request.get('deps', [])]
288
+ pool = self.config['routing'].get(difficulty) or self.config['routing'].get('any') or [parent['model']]
289
+ model = pool[(len(nodes) + len(validated) - 1) % len(pool)]
290
+ if model not in self.config['eligible']:
291
+ raise ValueError('Model not in frozen eligible pool')
292
+ validated.append(dict(name=name, goal=goal, difficulty=difficulty, paths=paths, deps=deps, model=model))
293
+ known = {n['id'] for n in nodes.values() if n['parent'] == parent['id']}
294
+ remaining = {v['name']: set(v['deps']) for v in validated}
295
+ while remaining:
296
+ ready = [key for key, deps in remaining.items() if deps <= known]
297
+ if not ready:
298
+ raise ValueError('Child dependencies are cyclic, missing, or outside sibling scope')
299
+ for key in ready:
300
+ known.add(key)
301
+ del remaining[key]
302
+ return validated
303
+
304
+ def children(self, parent: dict) -> None:
305
+ # Parents own no model slot while waiting. Overlapping candidates start only
306
+ # after earlier owners are imported, so their baseline includes those edits.
307
+ active = {}
308
+ with ThreadPoolExecutor(max_workers=self.limits['children']) as executor:
309
+ while True:
310
+ self.check(parent)
311
+ nodes = self.nodes()
312
+ children = [n for n in nodes.values() if n['parent'] == parent['id']]
313
+ remaining = [n for n in children if n['status'] != 'completed' and n['id'] not in active]
314
+ if not remaining and not active:
315
+ return
316
+ for node in remaining:
317
+ if any(nodes[d]['status'] != 'completed' for d in node['deps']):
318
+ continue
319
+ if any(overlap(node['paths'], n['paths']) for _, n in active.values()):
320
+ continue
321
+ active[node['id']] = (executor.submit(self.execute_node, node), node)
322
+ if not active:
323
+ raise Halt('dependency_failed')
324
+ done, _ = wait([v[0] for v in active.values()], timeout=.2, return_when=FIRST_COMPLETED)
325
+ for key, (future, node) in list(active.items()):
326
+ if future in done:
327
+ try:
328
+ future.result()
329
+ except BaseException:
330
+ self.cancel.set()
331
+ raise
332
+ del active[key]
333
+
334
+ def step(self, node: dict, workspace: Path, prompt: str) -> tuple[int, str]:
335
+ from .invocation import run_step
336
+ return run_step(self, node, workspace, prompt)
337
+
338
+ def import_node(self, node: dict) -> None:
339
+ with self.guard:
340
+ source, workspace = Path(node['source']), Path(node['workspace'])
341
+ end = artifact('forge_tree', workspace)
342
+ patch = command(['git', 'diff', '--binary', node['base'], end, '--', '.', ':(exclude).forge'], cwd=workspace)
343
+ changed = command(['git', 'diff', '--name-only', '-z', node['base'], end], cwd=workspace).decode().split('\0')
344
+ if any(p and not any(contains(scope, path_scope(p)) for scope in node['paths']) for p in changed):
345
+ raise Halt('scope_drift')
346
+ target = self.task / 'nodes' / node['id'] / 'candidate.diff'
347
+ target.write_bytes(patch)
348
+ intent_path = target.with_suffix('.import.json')
349
+ if intent_path.exists():
350
+ intent = read_json(intent_path)
351
+ if intent['patch_sha256'] == hashlib.sha256(patch).hexdigest() and artifact('forge_fingerprint', source) == intent['after']:
352
+ self.update(node, status='completed', ended_at=time.time(), candidate=str(target),
353
+ imported_fingerprint=intent['after'])
354
+ return
355
+ # Siblings with disjoint ownership can advance the parent. git apply
356
+ # checks exact affected content; root additionally checks all source state.
357
+ if not node['parent'] and artifact('forge_fingerprint', source) != node['fingerprint']:
358
+ raise Halt('source_drift')
359
+ if patch:
360
+ command(['git', 'apply', '--check', '--binary', target], cwd=source)
361
+ before = artifact('forge_fingerprint', source)
362
+ private_index = target.parent / ('import-index-' + uuid.uuid4().hex)
363
+ env = dict(os.environ, GIT_INDEX_FILE=str(private_index))
364
+ try:
365
+ for argv in (['git', 'read-tree', before.split()[1]], ['git', 'apply', '--cached', '--binary', str(target)]):
366
+ subprocess.run(argv, cwd=source, env=env, check=True, capture_output=True)
367
+ expected_tree = subprocess.check_output(['git', 'write-tree'], cwd=source, env=env).decode().strip()
368
+ finally:
369
+ private_index.unlink(missing_ok=True)
370
+ expected = before.split(); expected[1] = expected_tree
371
+ write_json(intent_path, dict(before=before, after=' '.join(expected), patch_sha256=hashlib.sha256(patch).hexdigest()))
372
+ if artifact('forge_fingerprint', source) != before:
373
+ raise Halt('source_drift')
374
+ command(['git', 'apply', '--binary', target], cwd=source)
375
+ if artifact('forge_fingerprint', source) != ' '.join(expected):
376
+ raise Halt('source_drift')
377
+ self.update(node, status='completed', ended_at=time.time(), candidate=str(target),
378
+ imported_fingerprint=artifact('forge_fingerprint', source))
379
+
380
+ def execute_node(self, node: dict) -> None:
381
+ from .decomposition import DecisionStage, implementation_prompt
382
+ if node['status'] == 'completed':
383
+ return
384
+ workspace = self.candidate(node)
385
+ try:
386
+ intent = self.task / 'nodes' / node['id'] / 'candidate.import.json'
387
+ if intent.exists() and artifact('forge_fingerprint', node['source']) == read_json(intent)['after']:
388
+ self.import_node(node)
389
+ return
390
+ automatic = self.config.get('decomposition', {}).get('enabled', False)
391
+ if automatic:
392
+ DecisionStage(self).ensure(node, workspace)
393
+ if node.get('awaiting_children'):
394
+ self.children(node)
395
+ self.update(node, awaiting_children=False)
396
+ while node['iteration'] < self.limits['iterations']:
397
+ self.pause_gate(node)
398
+ node['iteration'] += 1
399
+ prompt = implementation_prompt(self, node)
400
+ try:
401
+ rc, text = self.step(node, workspace, prompt)
402
+ except Halt as error:
403
+ if str(error) == 'paused':
404
+ node['iteration'] -= 1
405
+ self.pause_gate(node)
406
+ continue
407
+ raise
408
+ self.update(node, last=text)
409
+ if rc:
410
+ raise Halt('timeout' if rc == 7 else 'failed')
411
+ lines = [line for line in text.splitlines() if line.startswith('FORGE_FRACTAL: ')]
412
+ if not lines:
413
+ continue
414
+ response = json.loads(lines[-1].split(': ', 1)[1])
415
+ if response.get('children'):
416
+ if automatic:
417
+ raise Halt('unplanned_children')
418
+ self.admit(node, response['children'])
419
+ self.update(node, status='waiting', awaiting_children=True)
420
+ self.children(node)
421
+ self.update(node, awaiting_children=False)
422
+ elif response.get('done') is True:
423
+ self.import_node(node)
424
+ return
425
+ raise Halt('iteration_exhausted')
426
+ except BaseException as error:
427
+ self.update(node, status=str(error) if isinstance(error, Halt) else 'failed', error=str(error))
428
+ raise
429
+
430
+
431
+ def worker(run: Path, task: Path) -> int:
432
+ with lock(task / 'owner.lock', blocking=False):
433
+ reap_recorded(task)
434
+ tree = Tree(run, task)
435
+ def stop(signum, frame):
436
+ tree.cancel.set()
437
+ signal.signal(signal.SIGTERM, stop)
438
+ signal.signal(signal.SIGINT, stop)
439
+ state = read_json(task / 'state.json')
440
+ state.update(status='active', owner=os.getpid(), started_at=time.time())
441
+ write_json(task / 'state.json', state)
442
+ try:
443
+ tree.check()
444
+ tree.initialize()
445
+ tree.check()
446
+ tree.execute_node(tree.nodes()['implementation'])
447
+ state.update(status='completed', exit_code=0)
448
+ except BaseException as error:
449
+ state.update(status=str(error) if isinstance(error, Halt) else 'failed',
450
+ error=str(error), exit_code=7 if str(error) == 'timeout' else 4)
451
+ state.update(elapsed=tree.elapsed, ended_at=time.time(), owner=None)
452
+ write_json(task / 'state.json', state)
453
+ event(task, 'execution_settled', status=state['status'], acceptance='pending')
454
+ return state['exit_code']
455
+
456
+
457
+ def supervise(run: Path, task: Path) -> int:
458
+ """Bound setup and teardown too, including upstream subprocesses before steps."""
459
+ with lock(task / 'supervisor.lock', blocking=False):
460
+ limit = read_json(run / 'run.json')['limits']['deadline']
461
+ elapsed = read_json(task / 'state.json').get('elapsed', 0)
462
+ process = subprocess.Popen([str(runtime_python()), str(SCRIPTS / 'forge-fractal.py'),
463
+ '_worker', run.name, task.name], start_new_session=True)
464
+ previous = time.monotonic()
465
+ try:
466
+ while process.poll() is None:
467
+ time.sleep(.1)
468
+ now = time.monotonic()
469
+ state = read_json(task / 'state.json')
470
+ if state['status'] != 'paused':
471
+ elapsed += now - previous
472
+ previous = now
473
+ if elapsed >= limit:
474
+ reap_recorded(task)
475
+ terminate(process)
476
+ state = read_json(task / 'state.json')
477
+ state.update(status='timeout', elapsed=elapsed, owner=None, exit_code=7, settled=True)
478
+ write_json(task / 'state.json', state)
479
+ return 7
480
+ state = read_json(task / 'state.json')
481
+ if state['status'] in ('active', 'paused', 'pending'):
482
+ state.update(status='interrupted', exit_code=4, owner=None)
483
+ state['settled'] = True
484
+ write_json(task / 'state.json', state)
485
+ return process.returncode
486
+ finally:
487
+ if process.poll() is None:
488
+ reap_recorded(task)
489
+ terminate(process)
490
+
491
+
492
+ def launch(run: Path, task: Path) -> None:
493
+ name = 'forge-' + hashlib.sha256(str(task).encode()).hexdigest()[:24]
494
+ python = runtime_python()
495
+ argv = [str(python), str(SCRIPTS / 'forge-fractal.py'), '_supervise', run.name, task.name]
496
+ env = dict(os.environ, PATH=str(python.parent) + ':' + os.environ['PATH'],
497
+ PYTHONPATH=str(SCRIPTS) + os.pathsep + os.environ.get('PYTHONPATH', ''))
498
+ # Dedicated server socket; an already-running coordinator is not replaced.
499
+ script = task / 'launch.sh'
500
+ script.write_text('#!/bin/sh\nexec ' + shlex.join(argv) + ' >>' + shlex.quote(str(task / 'coordinator.log')) + ' 2>&1\n')
501
+ if subprocess.run(['tmux', '-L', name, 'has-session', '-t', name], capture_output=True).returncode == 0:
502
+ return
503
+ state = read_json(task / 'state.json')
504
+ state.update(status='pending', owner=None, settled=False)
505
+ write_json(task / 'state.json', state)
506
+ result = subprocess.run(['tmux', '-L', name, '-f', '/dev/null', 'new-session', '-d', '-s', name,
507
+ '/bin/sh ' + shlex.quote(str(script))], env=env, capture_output=True)
508
+ if result.returncode and b'duplicate session' not in result.stderr:
509
+ raise RuntimeError(result.stderr.decode())
510
+
511
+
512
+ def dispatch(argv: list[str]) -> int:
513
+ run = managed_run(Path(os.environ['FORGE_FRACTAL_RUN']).name)
514
+ role = argv[0]
515
+ if role != 'dwarf':
516
+ return limited_dispatch(run, argv)
517
+ def option(name: str, default: str = '') -> str:
518
+ return argv[argv.index(name) + 1] if name in argv else default
519
+ output = Path(option('--run-dir')).resolve()
520
+ source = Path(option('--repo')).resolve()
521
+ task_id = 'task-' + hashlib.sha256(str(output).encode()).hexdigest()[:16]
522
+ task = run / 'tasks' / task_id
523
+ task.mkdir(parents=True, exist_ok=True)
524
+ prompt = Path(option('--prompt-file')).read_text()
525
+ with lock(task / 'dispatch.lock', blocking=False):
526
+ request_path = task / 'request.json'
527
+ if not request_path.exists():
528
+ config = read_json(run / 'run.json')
529
+ paths = ['.']
530
+ if config['mode'] == 'parallel':
531
+ table = Path(config['runner']) / 'tasks.tsv'
532
+ row = next(line.split('\t') for line in table.read_text().splitlines()
533
+ if line and not line.startswith('#') and line.split('\t')[0] == output.name)
534
+ paths = [path_scope(p) for p in row[3].split(',')]
535
+ planner = config.get('decomposition', {}).get('planner') or argv[1]
536
+ write_json(request_path, dict(repo=str(source), output=str(output), model=argv[1], prompt=prompt,
537
+ planner=planner, paths=paths, step_timeout=int(option('--timeout', '0'))))
538
+ write_json(task / 'state.json', dict(status='pending', acceptance='pending', elapsed=0))
539
+ elif os.environ.get('FORGE_FRACTAL_RETRY') == '1':
540
+ from .pipeline import retry
541
+ retry(run, task, prompt, argv[1])
542
+ state = read_json(task / 'state.json')
543
+ if state['status'] != 'completed':
544
+ launch(run, task)
545
+ while True:
546
+ state = read_json(task / 'state.json')
547
+ if state['status'] not in ('pending', 'active', 'paused') and state.get('settled'):
548
+ break
549
+ name = 'forge-' + hashlib.sha256(str(task).encode()).hexdigest()[:24]
550
+ if subprocess.run(['tmux', '-L', name, 'has-session', '-t', name], capture_output=True).returncode:
551
+ state = read_json(task / 'state.json')
552
+ if state['status'] in ('pending', 'active', 'paused'):
553
+ state.update(status='interrupted', exit_code=4, error='Coordinator exited; inspect coordinator.log and resume explicitly')
554
+ write_json(task / 'state.json', state)
555
+ break
556
+ time.sleep(.2)
557
+ if state['status'] == 'completed':
558
+ node = read_json(task / 'nodes/implementation/node.json')
559
+ current = artifact('forge_fingerprint', source)
560
+ reviewed = output / 'reviewed.commit'
561
+ pinned_commit = (read_json(run / 'run.json')['mode'] == 'parallel' and reviewed.exists()
562
+ and current.split()[0] == reviewed.read_text().strip()
563
+ and current.split()[1] == node['imported_fingerprint'].split()[1])
564
+ if current != node['imported_fingerprint'] and not pinned_commit:
565
+ raise ValueError('Source drift after candidate import; saved completion cannot be reused')
566
+ (output / 'dwarf.last').write_text(node['last'])
567
+ step = task / node['step']
568
+ for suffix in ('log', 'resolved'):
569
+ if (step / ('dwarf.' + suffix)).exists():
570
+ shutil.copyfile(step / ('dwarf.' + suffix), output / ('dwarf.' + suffix))
571
+ return 0
572
+ print(json.dumps(state), file=sys.stderr)
573
+ return state.get('exit_code', 4)
574
+
575
+
576
+ def limited_dispatch(run: Path, argv: list[str]) -> int:
577
+ if argv[0] == 'doctor':
578
+ return subprocess.call(['/bin/bash', str(SCRIPTS / 'forge-dispatch.sh'), *argv])
579
+ limit = read_json(run / 'run.json')['limits']['deadline']
580
+ output = Path(argv[argv.index('--run-dir') + 1])
581
+ review = Path(argv[argv.index('--repo') + 1])
582
+ prompt = Path(argv[argv.index('--prompt-file') + 1])
583
+ task = run / 'tasks' / ('task-' + hashlib.sha256(str(output.resolve()).encode()).hexdigest()[:16])
584
+ checkpoint = task / 'qa-checkpoint.json'
585
+ key = dict(prompt=hashlib.sha256(prompt.read_bytes()).hexdigest(), tree=artifact('forge_tree', review),
586
+ model=argv[1], yolo='--yolo' in argv, native='--native-review' in argv)
587
+ if checkpoint.exists() and read_json(checkpoint)['key'] == key:
588
+ for suffix in ('last', 'log', 'resolved'):
589
+ shutil.copyfile(task / ('qa-accepted.' + suffix), output / ('qa.' + suffix))
590
+ return 0
591
+ rc = qa_dispatch(run, task, argv, limit)
592
+ if rc == 0 and artifact('forge_tree', review) == key['tree']:
593
+ last = output / 'qa.last'
594
+ # One parser for every runner (forge-artifact.sh), so a reply the decomposed runner
595
+ # accepts is accepted here too, and one it rejects is not checkpointed.
596
+ if last.exists() and artifact('forge_verdict', last) == 'PASS':
597
+ for suffix in ('last', 'log', 'resolved'):
598
+ shutil.copyfile(output / ('qa.' + suffix), task / ('qa-accepted.' + suffix))
599
+ write_json(checkpoint, dict(key=key))
600
+ return rc
601
+
602
+
603
+ def qa_dispatch(run: Path, task: Path, argv: list[str], limit: int) -> int:
604
+ state = read_json(task / 'state.json')
605
+ elapsed = state.get('elapsed', 0)
606
+ previous = time.monotonic()
607
+ def action() -> str:
608
+ actions = []
609
+ for target in ('all', task.name):
610
+ path = run / 'controls' / (target + '.json')
611
+ if path.exists():
612
+ actions.append(read_json(path)['action'])
613
+ return 'stop' if 'stop' in actions else ('pause' if 'pause' in actions else 'resume')
614
+
615
+ def check(allow_pause=False):
616
+ nonlocal elapsed, previous
617
+ now = time.monotonic()
618
+ current = action()
619
+ if current != 'pause':
620
+ elapsed += now - previous
621
+ previous = now
622
+ state.update(elapsed=elapsed, qa_status='paused' if current == 'pause' else 'active')
623
+ write_json(task / 'state.json', state)
624
+ if current == 'stop':
625
+ raise Halt('stopped')
626
+ if elapsed >= limit:
627
+ raise Halt('timeout')
628
+ if current == 'pause' and not allow_pause:
629
+ raise Halt('paused')
630
+
631
+ record = task / 'qa-process.json'
632
+ if record.exists():
633
+ old = read_json(record)
634
+ if old['identity'] and process_identity(old['pid']) == old['identity']:
635
+ with contextlib.suppress(ProcessLookupError):
636
+ os.killpg(old['pid'], signal.SIGKILL)
637
+ while True:
638
+ try:
639
+ with slot(run, check):
640
+ check()
641
+ process = subprocess.Popen(['/bin/bash', str(SCRIPTS / 'forge-dispatch.sh'), *argv], start_new_session=True)
642
+ write_json(record, dict(pid=process.pid, identity=process_identity(process.pid)))
643
+ try:
644
+ while process.poll() is None:
645
+ check()
646
+ time.sleep(.1)
647
+ state['qa_status'] = 'completed' if process.returncode == 0 else 'failed'
648
+ write_json(task / 'state.json', state)
649
+ return process.returncode
650
+ finally:
651
+ terminate(process)
652
+ except Halt as error:
653
+ if str(error) != 'paused':
654
+ state['qa_status'] = str(error)
655
+ write_json(task / 'state.json', state)
656
+ raise
657
+ while action() == 'pause':
658
+ check(allow_pause=True)
659
+ time.sleep(.1)