@giamme/forge 0.0.0-stage → 0.8.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.
- package/CHANGELOG.md +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -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)
|