juno-code 1.0.51 → 1.0.53
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/README.md +57 -0
- package/dist/bin/cli.js +678 -82
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/cli.mjs +675 -79
- package/dist/bin/cli.mjs.map +1 -1
- package/dist/index.d.mts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +369 -58
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +367 -56
- package/dist/index.mjs.map +1 -1
- package/dist/templates/scripts/__pycache__/parallel_runner.cpython-313.pyc +0 -0
- package/dist/templates/scripts/install_requirements.sh +21 -0
- package/dist/templates/scripts/kanban.sh +6 -2
- package/dist/templates/scripts/parallel_runner.sh +602 -131
- package/dist/templates/services/__pycache__/pi.cpython-313.pyc +0 -0
- package/dist/templates/services/__pycache__/pi.cpython-38.pyc +0 -0
- package/dist/templates/services/pi.py +418 -51
- package/dist/templates/skills/claude/ralph-loop/scripts/kanban.sh +6 -2
- package/dist/templates/skills/codex/ralph-loop/scripts/kanban.sh +6 -2
- package/package.json +1 -1
|
@@ -13,10 +13,12 @@ Usage:
|
|
|
13
13
|
./parallel_runner.sh --kanban TASK1 TASK2 TASK3 --parallel 2
|
|
14
14
|
./parallel_runner.sh --kanban "TASK1 TASK2 TASK3"
|
|
15
15
|
./parallel_runner.sh --kanban T1 T2 --prompt-file instructions.md
|
|
16
|
+
./parallel_runner.sh --kanban T1 T2 --prompt "Handle ## {{task_id}} with {{item}}"
|
|
17
|
+
echo "Handle ## {{task_id}}" | ./parallel_runner.sh --kanban T1 --prompt -
|
|
16
18
|
|
|
17
|
-
# Generic items mode (any list, requires --prompt-file with {{item}})
|
|
19
|
+
# Generic items mode (any list, requires --prompt-file/--prompt with {{item}})
|
|
18
20
|
./parallel_runner.sh --items "url1,url2,url3" --prompt-file crawl.md
|
|
19
|
-
./parallel_runner.sh --items shop1 shop2 shop3 --prompt
|
|
21
|
+
./parallel_runner.sh --items shop1 shop2 shop3 --prompt "Analyze {{item}}"
|
|
20
22
|
|
|
21
23
|
# File mode (structured data)
|
|
22
24
|
./parallel_runner.sh --items-file data.jsonl --prompt-file analyze.md
|
|
@@ -28,6 +30,7 @@ Usage:
|
|
|
28
30
|
./parallel_runner.sh --tmux --kanban T1 T2 --name my-batch
|
|
29
31
|
./parallel_runner.sh -s codex --kanban T1 T2
|
|
30
32
|
./parallel_runner.sh -s pi -m gpt-5 --kanban T1 T2
|
|
33
|
+
./parallel_runner.sh -s pi --subagent-args "--live" --kanban T1 T2
|
|
31
34
|
./parallel_runner.sh --stop # stop only running session
|
|
32
35
|
./parallel_runner.sh --stop --name my-batch # stop specific session
|
|
33
36
|
./parallel_runner.sh --stop-all # stop all sessions
|
|
@@ -58,7 +61,12 @@ Arguments:
|
|
|
58
61
|
-m, --model Model override. Env: JUNO_MODEL.
|
|
59
62
|
--env Environment overrides. KEY=VALUE pairs or path to .env file.
|
|
60
63
|
--prompt-file Path to a file whose content is appended to the prompt.
|
|
61
|
-
|
|
64
|
+
Loaded once at startup; per-task prompt files are materialized under logs/tmp.
|
|
65
|
+
Placeholders: {{task_id}}, {{item}}, {{file_format}}.
|
|
66
|
+
--prompt Inline prompt template content (same placeholders as --prompt-file).
|
|
67
|
+
Use --prompt - to read template content from stdin/heredoc.
|
|
68
|
+
--subagent-args Extra raw args appended to each juno-code invocation.
|
|
69
|
+
Example: --subagent-args "--live --thinking high"
|
|
62
70
|
--tmux Run in tmux mode. 'windows' (default) or 'panes' (side-by-side).
|
|
63
71
|
--name Session name (default: auto-generated batch-N). Tmux session = pc-{name}.
|
|
64
72
|
--output-dir Structured output directory. Default: /tmp/juno-code-sessions/{date}/{run_id}.
|
|
@@ -102,6 +110,10 @@ COMBINED_LOG = LOG_DIR / "parallel_runner.log" # overwritten in main()
|
|
|
102
110
|
# 5-char alphanumeric run ID, generated once at startup in main()
|
|
103
111
|
_run_id = ""
|
|
104
112
|
|
|
113
|
+
# Temporary runtime artifacts are stored under logs/tmp and purged periodically.
|
|
114
|
+
_TMP_DIR_NAME = "tmp"
|
|
115
|
+
_TMP_STALE_MAX_AGE_SECONDS = 48 * 60 * 60
|
|
116
|
+
|
|
105
117
|
# Thread-safe lock for writing to the shared combined log
|
|
106
118
|
_log_lock = threading.Lock()
|
|
107
119
|
|
|
@@ -203,16 +215,36 @@ def _session_name_to_tmux(name):
|
|
|
203
215
|
return f"pc-{name}"
|
|
204
216
|
|
|
205
217
|
|
|
218
|
+
def _tmp_root():
|
|
219
|
+
return _log_base / _TMP_DIR_NAME
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _session_state_root():
|
|
223
|
+
"""Shared state/lock files (dashboard, pause, pid) under logs/tmp."""
|
|
224
|
+
root = _tmp_root() / ".session_state"
|
|
225
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
return root
|
|
227
|
+
|
|
228
|
+
|
|
206
229
|
def _dashboard_file(name):
|
|
207
|
-
return
|
|
230
|
+
return _session_state_root() / f"dashboard_{name}"
|
|
208
231
|
|
|
209
232
|
|
|
210
233
|
def _pause_file(name):
|
|
211
|
-
return
|
|
234
|
+
return _session_state_root() / f"pause_{name}"
|
|
212
235
|
|
|
213
236
|
|
|
214
237
|
def _pid_file(name):
|
|
215
|
-
return
|
|
238
|
+
return _session_state_root() / f"orchestrator_pid_{name}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _legacy_session_state_files(name):
|
|
242
|
+
"""Legacy state/lock files under logs/ root from older runner versions."""
|
|
243
|
+
return [
|
|
244
|
+
_log_base / f".dashboard_{name}",
|
|
245
|
+
_log_base / f".pause_{name}",
|
|
246
|
+
_log_base / f".orchestrator_pid_{name}",
|
|
247
|
+
]
|
|
216
248
|
|
|
217
249
|
|
|
218
250
|
def _orchestrator_log(name):
|
|
@@ -220,11 +252,61 @@ def _orchestrator_log(name):
|
|
|
220
252
|
|
|
221
253
|
|
|
222
254
|
def _tmp_dir(name):
|
|
223
|
-
|
|
255
|
+
# Keep runtime artifacts under logs/tmp so long-running sessions are
|
|
256
|
+
# resilient to OS cleanup jobs and temp lifecycle is centralized.
|
|
257
|
+
return _tmp_root() / name
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _legacy_tmp_paths():
|
|
261
|
+
"""Legacy temporary paths from older runner versions (.tmp_*)."""
|
|
262
|
+
return list(_log_base.glob(".tmp_*"))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _cleanup_tmp_path(path):
|
|
266
|
+
"""Remove a tmp file/dir path safely."""
|
|
267
|
+
try:
|
|
268
|
+
if path.is_dir():
|
|
269
|
+
shutil.rmtree(str(path), ignore_errors=True)
|
|
270
|
+
else:
|
|
271
|
+
path.unlink(missing_ok=True)
|
|
272
|
+
return True
|
|
273
|
+
except OSError:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def cleanup_stale_tmp_artifacts(max_age_seconds=_TMP_STALE_MAX_AGE_SECONDS):
|
|
278
|
+
"""Remove stale tmp artifacts older than max_age_seconds.
|
|
279
|
+
|
|
280
|
+
Returns number of removed paths.
|
|
281
|
+
"""
|
|
282
|
+
now = time.time()
|
|
283
|
+
removed = 0
|
|
284
|
+
|
|
285
|
+
candidates = []
|
|
286
|
+
tmp_root = _tmp_root()
|
|
287
|
+
if tmp_root.exists():
|
|
288
|
+
for path in tmp_root.iterdir():
|
|
289
|
+
if path.name.startswith('.'):
|
|
290
|
+
continue
|
|
291
|
+
candidates.append(path)
|
|
292
|
+
|
|
293
|
+
candidates.extend(_legacy_tmp_paths())
|
|
294
|
+
|
|
295
|
+
for path in candidates:
|
|
296
|
+
try:
|
|
297
|
+
age = now - path.stat().st_mtime
|
|
298
|
+
except OSError:
|
|
299
|
+
continue
|
|
300
|
+
if age < max_age_seconds:
|
|
301
|
+
continue
|
|
302
|
+
if _cleanup_tmp_path(path):
|
|
303
|
+
removed += 1
|
|
304
|
+
|
|
305
|
+
return removed
|
|
224
306
|
|
|
225
307
|
|
|
226
308
|
def _write_log_pipe_helper(name):
|
|
227
|
-
"""Write the Python log-pipe helper
|
|
309
|
+
"""Write the Python log-pipe helper under logs/tmp (once per session)."""
|
|
228
310
|
tmp = _tmp_dir(name)
|
|
229
311
|
tmp.mkdir(parents=True, exist_ok=True)
|
|
230
312
|
helper_path = tmp / "log_pipe.py"
|
|
@@ -310,6 +392,51 @@ def _generate_env_exports():
|
|
|
310
392
|
return "\n".join(lines)
|
|
311
393
|
|
|
312
394
|
|
|
395
|
+
def _resolve_subagent_args(raw_args):
|
|
396
|
+
"""Resolve repeatable --subagent-args strings into argv tokens."""
|
|
397
|
+
resolved = []
|
|
398
|
+
if not raw_args:
|
|
399
|
+
return resolved
|
|
400
|
+
for raw in raw_args:
|
|
401
|
+
try:
|
|
402
|
+
resolved.extend(shlex.split(raw))
|
|
403
|
+
except ValueError as exc:
|
|
404
|
+
print(f"ERROR: Invalid --subagent-args value '{raw}': {exc}", file=sys.stderr)
|
|
405
|
+
sys.exit(2)
|
|
406
|
+
return resolved
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _normalize_subagent_args_argv(argv):
|
|
410
|
+
"""Normalize common `subagent-args` invocation shapes before argparse.
|
|
411
|
+
|
|
412
|
+
Supports:
|
|
413
|
+
- `--subagent-args --live` (dash-prefixed value)
|
|
414
|
+
- `subagent-args --live` (missing leading `--` typo)
|
|
415
|
+
"""
|
|
416
|
+
normalized = []
|
|
417
|
+
i = 0
|
|
418
|
+
while i < len(argv):
|
|
419
|
+
token = argv[i]
|
|
420
|
+
if token == "subagent-args":
|
|
421
|
+
token = "--subagent-args"
|
|
422
|
+
if token == "--subagent-args" and i + 1 < len(argv):
|
|
423
|
+
next_token = argv[i + 1]
|
|
424
|
+
normalized.append(f"--subagent-args={next_token}")
|
|
425
|
+
i += 2
|
|
426
|
+
continue
|
|
427
|
+
normalized.append(token)
|
|
428
|
+
i += 1
|
|
429
|
+
return normalized
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _contains_live_subagent_flag(subagent_args):
|
|
433
|
+
"""Detect Pi live-mode flags in resolved --subagent-args tokens."""
|
|
434
|
+
for token in subagent_args or []:
|
|
435
|
+
if token == "--live" or token.startswith("--live="):
|
|
436
|
+
return True
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
|
|
313
440
|
# ---------------------------------------------------------------------------
|
|
314
441
|
# File parsing — multi-format pipeline
|
|
315
442
|
# ---------------------------------------------------------------------------
|
|
@@ -544,17 +671,8 @@ def _task_output_path(output_dir, task_id):
|
|
|
544
671
|
return output_dir / f"{task_id}.json"
|
|
545
672
|
|
|
546
673
|
|
|
547
|
-
def
|
|
548
|
-
"""Parse the juno-code result event from
|
|
549
|
-
|
|
550
|
-
juno-code prints a JSON line with {"type":"result",...} to stdout.
|
|
551
|
-
We walk backwards to find it near the end.
|
|
552
|
-
"""
|
|
553
|
-
try:
|
|
554
|
-
lines = Path(task_log_path).read_text(encoding="utf-8").splitlines()
|
|
555
|
-
except (OSError, UnicodeDecodeError):
|
|
556
|
-
return None
|
|
557
|
-
|
|
674
|
+
def _parse_result_from_lines(lines):
|
|
675
|
+
"""Parse the most recent juno-code result event from text lines."""
|
|
558
676
|
for line in reversed(lines):
|
|
559
677
|
idx = line.find('{"type":')
|
|
560
678
|
if idx == -1:
|
|
@@ -569,6 +687,147 @@ def _parse_result_from_log(task_log_path):
|
|
|
569
687
|
return None
|
|
570
688
|
|
|
571
689
|
|
|
690
|
+
def _parse_result_from_cli_summary_text(clean_text):
|
|
691
|
+
"""Build a synthetic result payload from juno-code CLI summary text.
|
|
692
|
+
|
|
693
|
+
This is a fallback when no structured {"type":"result"} object is present in logs.
|
|
694
|
+
"""
|
|
695
|
+
if not clean_text:
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
lines = clean_text.splitlines()
|
|
699
|
+
|
|
700
|
+
total_cost_usd = None
|
|
701
|
+
total_cost_match = re.search(r"Total Cost:\s*\$([0-9]+(?:\.[0-9]+)?)", clean_text)
|
|
702
|
+
if total_cost_match:
|
|
703
|
+
total_cost_usd = _to_number(total_cost_match.group(1))
|
|
704
|
+
|
|
705
|
+
session_id = None
|
|
706
|
+
# Handles both:
|
|
707
|
+
# "Iteration 1: <session> cost: $..."
|
|
708
|
+
# "<session> cost: $..."
|
|
709
|
+
session_cost_patterns = [
|
|
710
|
+
r"Iteration\s+\d+:\s*([^\s]+)\s+cost:\s*\$([0-9]+(?:\.[0-9]+)?)",
|
|
711
|
+
r"^\s*([^\s]+)\s+cost:\s*\$([0-9]+(?:\.[0-9]+)?)\s*$",
|
|
712
|
+
]
|
|
713
|
+
|
|
714
|
+
per_session_cost = None
|
|
715
|
+
for line in lines:
|
|
716
|
+
for pattern in session_cost_patterns:
|
|
717
|
+
m = re.search(pattern, line)
|
|
718
|
+
if not m:
|
|
719
|
+
continue
|
|
720
|
+
session_id = m.group(1).strip()
|
|
721
|
+
per_session_cost = _to_number(m.group(2))
|
|
722
|
+
|
|
723
|
+
# Fallback for "Session ID:" section without inline cost.
|
|
724
|
+
if not session_id:
|
|
725
|
+
for idx, line in enumerate(lines):
|
|
726
|
+
if "Session ID" not in line:
|
|
727
|
+
continue
|
|
728
|
+
for nxt in lines[idx + 1: idx + 4]:
|
|
729
|
+
token = nxt.strip()
|
|
730
|
+
if not token or token.startswith("🔑") or token.startswith("-"):
|
|
731
|
+
continue
|
|
732
|
+
session_id = token.split()[0]
|
|
733
|
+
break
|
|
734
|
+
if session_id:
|
|
735
|
+
break
|
|
736
|
+
|
|
737
|
+
result_text = None
|
|
738
|
+
for idx, line in enumerate(lines):
|
|
739
|
+
if "📄 Result:" in line or line.strip() == "Result:":
|
|
740
|
+
collected = []
|
|
741
|
+
for nxt in lines[idx + 1:]:
|
|
742
|
+
if (
|
|
743
|
+
"📊 Statistics:" in nxt
|
|
744
|
+
or nxt.strip() == "Statistics:"
|
|
745
|
+
or "🔑 Session ID" in nxt
|
|
746
|
+
):
|
|
747
|
+
break
|
|
748
|
+
if nxt.strip():
|
|
749
|
+
collected.append(nxt)
|
|
750
|
+
if collected:
|
|
751
|
+
result_text = "\n".join(collected).strip()
|
|
752
|
+
break
|
|
753
|
+
|
|
754
|
+
if total_cost_usd is None and per_session_cost is not None:
|
|
755
|
+
total_cost_usd = per_session_cost
|
|
756
|
+
|
|
757
|
+
if session_id is None and total_cost_usd is None and not result_text:
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
is_error = ("Execution failed" in clean_text) or ("❌" in clean_text)
|
|
761
|
+
|
|
762
|
+
payload = {
|
|
763
|
+
"type": "result",
|
|
764
|
+
"subtype": "error" if is_error else "success",
|
|
765
|
+
"is_error": is_error,
|
|
766
|
+
}
|
|
767
|
+
if session_id:
|
|
768
|
+
payload["session_id"] = session_id
|
|
769
|
+
if total_cost_usd is not None:
|
|
770
|
+
payload["total_cost_usd"] = total_cost_usd
|
|
771
|
+
if result_text:
|
|
772
|
+
payload["result"] = result_text
|
|
773
|
+
|
|
774
|
+
return payload
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _parse_result_from_text(text):
|
|
778
|
+
"""Parse latest juno-code result event from text output.
|
|
779
|
+
|
|
780
|
+
Supports:
|
|
781
|
+
- single-line JSON event logs
|
|
782
|
+
- pretty/multi-line JSON blocks
|
|
783
|
+
- fallback CLI summary lines (session id / cost / result text)
|
|
784
|
+
"""
|
|
785
|
+
if not text:
|
|
786
|
+
return None
|
|
787
|
+
|
|
788
|
+
# Fast path for compact line-based JSON logs.
|
|
789
|
+
parsed = _parse_result_from_lines(text.splitlines())
|
|
790
|
+
if parsed is not None:
|
|
791
|
+
return parsed
|
|
792
|
+
|
|
793
|
+
ansi_re = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
|
|
794
|
+
clean = ansi_re.sub('', text)
|
|
795
|
+
|
|
796
|
+
decoder = json.JSONDecoder()
|
|
797
|
+
idx = 0
|
|
798
|
+
best = None
|
|
799
|
+
|
|
800
|
+
while True:
|
|
801
|
+
brace = clean.find('{', idx)
|
|
802
|
+
if brace == -1:
|
|
803
|
+
break
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
obj, end = decoder.raw_decode(clean, brace)
|
|
807
|
+
except json.JSONDecodeError:
|
|
808
|
+
idx = brace + 1
|
|
809
|
+
continue
|
|
810
|
+
|
|
811
|
+
if isinstance(obj, dict) and obj.get("type") == "result":
|
|
812
|
+
best = obj
|
|
813
|
+
idx = max(end, brace + 1)
|
|
814
|
+
|
|
815
|
+
if best is not None:
|
|
816
|
+
return best
|
|
817
|
+
|
|
818
|
+
return _parse_result_from_cli_summary_text(clean)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _parse_result_from_log(task_log_path):
|
|
822
|
+
"""Parse the juno-code result event from a task log file."""
|
|
823
|
+
try:
|
|
824
|
+
text = Path(task_log_path).read_text(encoding="utf-8")
|
|
825
|
+
except (OSError, UnicodeDecodeError):
|
|
826
|
+
return None
|
|
827
|
+
|
|
828
|
+
return _parse_result_from_text(text)
|
|
829
|
+
|
|
830
|
+
|
|
572
831
|
def _extract_response(backend_result, file_format):
|
|
573
832
|
"""Extract the clean response from a backend result.
|
|
574
833
|
|
|
@@ -596,6 +855,65 @@ def _extract_response(backend_result, file_format):
|
|
|
596
855
|
return raw_result, error_msg
|
|
597
856
|
|
|
598
857
|
|
|
858
|
+
def _to_number(value):
|
|
859
|
+
if isinstance(value, bool):
|
|
860
|
+
return None
|
|
861
|
+
if isinstance(value, (int, float)):
|
|
862
|
+
return float(value)
|
|
863
|
+
if isinstance(value, str):
|
|
864
|
+
s = value.strip()
|
|
865
|
+
if not s:
|
|
866
|
+
return None
|
|
867
|
+
try:
|
|
868
|
+
return float(s)
|
|
869
|
+
except ValueError:
|
|
870
|
+
return None
|
|
871
|
+
return None
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _extract_total_cost_usd(payload):
|
|
875
|
+
"""Extract total USD cost from structured backend payload variants."""
|
|
876
|
+
if not isinstance(payload, dict):
|
|
877
|
+
return None
|
|
878
|
+
|
|
879
|
+
for key in ("total_cost_usd", "totalCostUsd", "totalCostUSD"):
|
|
880
|
+
direct = _to_number(payload.get(key))
|
|
881
|
+
if direct is not None:
|
|
882
|
+
return direct
|
|
883
|
+
|
|
884
|
+
usage = payload.get("usage")
|
|
885
|
+
if isinstance(usage, dict):
|
|
886
|
+
usage_cost = usage.get("cost")
|
|
887
|
+
if isinstance(usage_cost, dict):
|
|
888
|
+
nested = _to_number(usage_cost.get("total"))
|
|
889
|
+
if nested is not None:
|
|
890
|
+
return nested
|
|
891
|
+
|
|
892
|
+
return None
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _extract_session_id(payload):
|
|
896
|
+
"""Extract session id from structured backend payload variants."""
|
|
897
|
+
if not isinstance(payload, dict):
|
|
898
|
+
return None
|
|
899
|
+
|
|
900
|
+
direct = payload.get("session_id")
|
|
901
|
+
if isinstance(direct, str) and direct.strip():
|
|
902
|
+
return direct.strip()
|
|
903
|
+
|
|
904
|
+
camel = payload.get("sessionId")
|
|
905
|
+
if isinstance(camel, str) and camel.strip():
|
|
906
|
+
return camel.strip()
|
|
907
|
+
|
|
908
|
+
nested = payload.get("sub_agent_response")
|
|
909
|
+
if isinstance(nested, dict):
|
|
910
|
+
sub_id = nested.get("session_id")
|
|
911
|
+
if isinstance(sub_id, str) and sub_id.strip():
|
|
912
|
+
return sub_id.strip()
|
|
913
|
+
|
|
914
|
+
return None
|
|
915
|
+
|
|
916
|
+
|
|
599
917
|
def _write_task_output(output_dir, task_id, exit_code, wall_time, start_time,
|
|
600
918
|
end_time, worker_id=-1,
|
|
601
919
|
extracted_response=None, extraction_error=None,
|
|
@@ -608,13 +926,13 @@ def _write_task_output(output_dir, task_id, exit_code, wall_time, start_time,
|
|
|
608
926
|
backend_result, file_format,
|
|
609
927
|
)
|
|
610
928
|
|
|
611
|
-
session_id =
|
|
612
|
-
|
|
613
|
-
session_id = backend_result.get("session_id")
|
|
929
|
+
session_id = _extract_session_id(backend_result)
|
|
930
|
+
total_cost_usd = _extract_total_cost_usd(backend_result)
|
|
614
931
|
|
|
615
932
|
task_output = {
|
|
616
933
|
"task_id": task_id,
|
|
617
934
|
"session_id": session_id,
|
|
935
|
+
"total_cost_usd": total_cost_usd,
|
|
618
936
|
"exit_code": exit_code,
|
|
619
937
|
"wall_time_seconds": round(wall_time, 2),
|
|
620
938
|
"start_time": start_time,
|
|
@@ -695,11 +1013,27 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
|
|
|
695
1013
|
merged_parts = []
|
|
696
1014
|
failed_ids = []
|
|
697
1015
|
failed_sessions = {}
|
|
1016
|
+
session_rows = []
|
|
1017
|
+
total_cost_usd = 0.0
|
|
698
1018
|
for tid in sorted(task_outputs.keys()):
|
|
699
1019
|
t = task_outputs[tid]
|
|
700
1020
|
er = t.get("extracted_response")
|
|
701
1021
|
br = t.get("backend_result") or {}
|
|
702
1022
|
backend_ok = isinstance(br, dict) and br.get("exit_code", -1) == 0
|
|
1023
|
+
sid = t.get("session_id")
|
|
1024
|
+
task_cost = _to_number(t.get("total_cost_usd"))
|
|
1025
|
+
if task_cost is None:
|
|
1026
|
+
task_cost = _extract_total_cost_usd(br)
|
|
1027
|
+
if task_cost is not None:
|
|
1028
|
+
total_cost_usd += task_cost
|
|
1029
|
+
|
|
1030
|
+
session_rows.append({
|
|
1031
|
+
"task_id": tid,
|
|
1032
|
+
"session_id": sid,
|
|
1033
|
+
"total_cost_usd": task_cost,
|
|
1034
|
+
"exit_code": t.get("exit_code"),
|
|
1035
|
+
})
|
|
1036
|
+
|
|
703
1037
|
if er and (t.get("exit_code") == 0 or backend_ok):
|
|
704
1038
|
if file_format == "csv" and merged_parts:
|
|
705
1039
|
lines = er.split("\n")
|
|
@@ -709,7 +1043,6 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
|
|
|
709
1043
|
merged_parts.append(er)
|
|
710
1044
|
else:
|
|
711
1045
|
failed_ids.append(tid)
|
|
712
|
-
sid = t.get("session_id")
|
|
713
1046
|
if sid:
|
|
714
1047
|
failed_sessions[tid] = sid
|
|
715
1048
|
|
|
@@ -727,6 +1060,11 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
|
|
|
727
1060
|
"parallelism": parallelism,
|
|
728
1061
|
"mode": mode,
|
|
729
1062
|
"session_name": session_name,
|
|
1063
|
+
"total_cost_usd": round(total_cost_usd, 10),
|
|
1064
|
+
},
|
|
1065
|
+
"session_summary": {
|
|
1066
|
+
"total_cost_usd": round(total_cost_usd, 10),
|
|
1067
|
+
"tasks": session_rows,
|
|
730
1068
|
},
|
|
731
1069
|
"merged_extracted": merged_extracted,
|
|
732
1070
|
"tasks": task_outputs,
|
|
@@ -750,6 +1088,7 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
|
|
|
750
1088
|
"failed_ids": failed_ids,
|
|
751
1089
|
"failed_sessions": failed_sessions,
|
|
752
1090
|
"error_count": len(failed_ids),
|
|
1091
|
+
"total_cost_usd": round(total_cost_usd, 10),
|
|
753
1092
|
}
|
|
754
1093
|
|
|
755
1094
|
|
|
@@ -763,6 +1102,9 @@ def _format_output_summary(agg_result):
|
|
|
763
1102
|
lines.append(f" Aggregation: {agg_result['agg_path']}")
|
|
764
1103
|
if agg_result["merged_path"]:
|
|
765
1104
|
lines.append(f" Merged file: {agg_result['merged_path']}")
|
|
1105
|
+
total_cost_usd = agg_result.get("total_cost_usd")
|
|
1106
|
+
if total_cost_usd is not None:
|
|
1107
|
+
lines.append(f" Total cost: ${total_cost_usd:.6f} USD")
|
|
766
1108
|
if agg_result["error_count"] > 0:
|
|
767
1109
|
lines.append(f" Errors: {agg_result['error_count']} chunks failed extraction")
|
|
768
1110
|
lines.append(f" Failed IDs: {', '.join(agg_result['failed_ids'])}")
|
|
@@ -790,6 +1132,14 @@ def _print_output_summary(agg_result):
|
|
|
790
1132
|
# Auto-naming
|
|
791
1133
|
# ---------------------------------------------------------------------------
|
|
792
1134
|
|
|
1135
|
+
def _iter_pid_files():
|
|
1136
|
+
"""Yield current + legacy PID marker files for running-session discovery."""
|
|
1137
|
+
state_root = _session_state_root()
|
|
1138
|
+
if state_root.exists():
|
|
1139
|
+
yield from state_root.glob("orchestrator_pid_*")
|
|
1140
|
+
yield from _log_base.glob(".orchestrator_pid_*")
|
|
1141
|
+
|
|
1142
|
+
|
|
793
1143
|
def _next_batch_name():
|
|
794
1144
|
"""Find the next available batch-N name."""
|
|
795
1145
|
existing = set()
|
|
@@ -809,8 +1159,13 @@ def _next_batch_name():
|
|
|
809
1159
|
except Exception:
|
|
810
1160
|
pass
|
|
811
1161
|
try:
|
|
812
|
-
for f in
|
|
813
|
-
|
|
1162
|
+
for f in _iter_pid_files():
|
|
1163
|
+
if f.name.startswith("orchestrator_pid_batch-"):
|
|
1164
|
+
suffix = f.name[len("orchestrator_pid_batch-"):]
|
|
1165
|
+
elif f.name.startswith(".orchestrator_pid_batch-"):
|
|
1166
|
+
suffix = f.name[len(".orchestrator_pid_batch-"):]
|
|
1167
|
+
else:
|
|
1168
|
+
continue
|
|
814
1169
|
try:
|
|
815
1170
|
existing.add(int(suffix))
|
|
816
1171
|
except ValueError:
|
|
@@ -830,13 +1185,22 @@ def _next_batch_name():
|
|
|
830
1185
|
def _list_running_sessions():
|
|
831
1186
|
"""Return list of (name, pid) tuples for running sessions."""
|
|
832
1187
|
sessions = []
|
|
1188
|
+
seen_names = set()
|
|
833
1189
|
try:
|
|
834
|
-
for f in
|
|
835
|
-
|
|
1190
|
+
for f in _iter_pid_files():
|
|
1191
|
+
if f.name.startswith("orchestrator_pid_"):
|
|
1192
|
+
name = f.name[len("orchestrator_pid_"):]
|
|
1193
|
+
elif f.name.startswith(".orchestrator_pid_"):
|
|
1194
|
+
name = f.name[len(".orchestrator_pid_"):]
|
|
1195
|
+
else:
|
|
1196
|
+
continue
|
|
1197
|
+
if name in seen_names:
|
|
1198
|
+
continue
|
|
836
1199
|
try:
|
|
837
1200
|
pid = int(f.read_text().strip())
|
|
838
1201
|
os.kill(pid, 0)
|
|
839
1202
|
sessions.append((name, pid))
|
|
1203
|
+
seen_names.add(name)
|
|
840
1204
|
except (ValueError, ProcessLookupError, PermissionError):
|
|
841
1205
|
pass
|
|
842
1206
|
except Exception:
|
|
@@ -867,7 +1231,7 @@ def _stop_session(name):
|
|
|
867
1231
|
print(f" Killed tmux session '{tmux_session}'")
|
|
868
1232
|
stopped = True
|
|
869
1233
|
|
|
870
|
-
for f in [pid_path, _dashboard_file(name), _pause_file(name)]:
|
|
1234
|
+
for f in [pid_path, _dashboard_file(name), _pause_file(name), *_legacy_session_state_files(name)]:
|
|
871
1235
|
try:
|
|
872
1236
|
f.unlink(missing_ok=True)
|
|
873
1237
|
except OSError:
|
|
@@ -877,6 +1241,11 @@ def _stop_session(name):
|
|
|
877
1241
|
if tmp.exists():
|
|
878
1242
|
shutil.rmtree(str(tmp), ignore_errors=True)
|
|
879
1243
|
|
|
1244
|
+
# Backward-compat cleanup for legacy path style.
|
|
1245
|
+
legacy_tmp = _log_base / f".tmp_{name}"
|
|
1246
|
+
if legacy_tmp.exists():
|
|
1247
|
+
shutil.rmtree(str(legacy_tmp), ignore_errors=True)
|
|
1248
|
+
|
|
880
1249
|
return stopped
|
|
881
1250
|
|
|
882
1251
|
|
|
@@ -1017,7 +1386,15 @@ def parse_args():
|
|
|
1017
1386
|
)
|
|
1018
1387
|
parser.add_argument(
|
|
1019
1388
|
"--prompt-file", type=str, default=None,
|
|
1020
|
-
help="Prompt template file.
|
|
1389
|
+
help="Prompt template file. Loaded once at startup. Placeholders: {{task_id}}, {{item}}, {{file_format}}.",
|
|
1390
|
+
)
|
|
1391
|
+
parser.add_argument(
|
|
1392
|
+
"--prompt", type=str, default=None,
|
|
1393
|
+
help="Inline prompt template content. Use '-' to read from stdin. Placeholders: {{task_id}}, {{item}}, {{file_format}}.",
|
|
1394
|
+
)
|
|
1395
|
+
parser.add_argument(
|
|
1396
|
+
"--subagent-args", action="append", default=None,
|
|
1397
|
+
help="Extra raw args appended to each juno-code invocation. Repeatable; values are shell-split.",
|
|
1021
1398
|
)
|
|
1022
1399
|
parser.add_argument(
|
|
1023
1400
|
"--tmux", nargs="?", const="windows", default=None, choices=["windows", "panes"],
|
|
@@ -1039,7 +1416,8 @@ def parse_args():
|
|
|
1039
1416
|
"--stop-all", action="store_true", default=False,
|
|
1040
1417
|
help="Stop ALL running sessions.",
|
|
1041
1418
|
)
|
|
1042
|
-
|
|
1419
|
+
normalized_argv = _normalize_subagent_args_argv(sys.argv[1:])
|
|
1420
|
+
args = parser.parse_args(normalized_argv)
|
|
1043
1421
|
|
|
1044
1422
|
# Handle stop commands first
|
|
1045
1423
|
if args.stop_all:
|
|
@@ -1047,6 +1425,9 @@ def parse_args():
|
|
|
1047
1425
|
if args.stop:
|
|
1048
1426
|
return args
|
|
1049
1427
|
|
|
1428
|
+
if args.prompt_file and args.prompt:
|
|
1429
|
+
parser.error("Use either --prompt-file or --prompt, not both")
|
|
1430
|
+
|
|
1050
1431
|
# Resolve --kanban-filter -> --kanban
|
|
1051
1432
|
if args.kanban_filter:
|
|
1052
1433
|
if args.kanban:
|
|
@@ -1089,6 +1470,21 @@ def parse_args():
|
|
|
1089
1470
|
global _env_overrides
|
|
1090
1471
|
_env_overrides = _resolve_env_overrides(args.env)
|
|
1091
1472
|
|
|
1473
|
+
args.subagent_args_list = _resolve_subagent_args(args.subagent_args)
|
|
1474
|
+
|
|
1475
|
+
live_in_tmux = args.tmux and _contains_live_subagent_flag(args.subagent_args_list)
|
|
1476
|
+
if live_in_tmux and args.parallel > 1:
|
|
1477
|
+
parser.error(
|
|
1478
|
+
"--tmux with --subagent-args '--live' is interactive and only supports --parallel 1. "
|
|
1479
|
+
"Set --parallel 1, remove --live, or run headless mode."
|
|
1480
|
+
)
|
|
1481
|
+
if live_in_tmux:
|
|
1482
|
+
print(
|
|
1483
|
+
"WARNING: --tmux with --subagent-args '--live' enables interactive Pi TUI in the worker pane; "
|
|
1484
|
+
"batch progress resumes after you exit that live session.",
|
|
1485
|
+
file=sys.stderr,
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1092
1488
|
# Flatten --kanban
|
|
1093
1489
|
if args.kanban:
|
|
1094
1490
|
flat = []
|
|
@@ -1153,17 +1549,56 @@ def format_duration(seconds):
|
|
|
1153
1549
|
return f"{hours}h {mins}m {secs:.0f}s"
|
|
1154
1550
|
|
|
1155
1551
|
|
|
1156
|
-
def
|
|
1157
|
-
"""Resolve
|
|
1158
|
-
if
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1552
|
+
def resolve_prompt_source(args, pwd):
|
|
1553
|
+
"""Resolve prompt source metadata and return (source_label, template_text)."""
|
|
1554
|
+
if args.prompt_file:
|
|
1555
|
+
prompt_path = Path(args.prompt_file)
|
|
1556
|
+
if not prompt_path.is_absolute():
|
|
1557
|
+
prompt_path = Path(pwd) / prompt_path
|
|
1558
|
+
if not prompt_path.exists():
|
|
1559
|
+
print(f"ERROR: Prompt file not found: {prompt_path}", file=sys.stderr)
|
|
1560
|
+
sys.exit(2)
|
|
1561
|
+
try:
|
|
1562
|
+
template = prompt_path.read_text(encoding="utf-8")
|
|
1563
|
+
except Exception as exc:
|
|
1564
|
+
print(f"ERROR: Could not read prompt file at startup: {exc}", file=sys.stderr)
|
|
1565
|
+
sys.exit(2)
|
|
1566
|
+
return str(prompt_path), template
|
|
1567
|
+
|
|
1568
|
+
if args.prompt is not None:
|
|
1569
|
+
if args.prompt == "-":
|
|
1570
|
+
if sys.stdin.isatty():
|
|
1571
|
+
print("ERROR: --prompt - requires redirected stdin (pipe/heredoc)", file=sys.stderr)
|
|
1572
|
+
sys.exit(2)
|
|
1573
|
+
template = sys.stdin.read()
|
|
1574
|
+
if not template.strip():
|
|
1575
|
+
print("ERROR: --prompt - received empty stdin content", file=sys.stderr)
|
|
1576
|
+
sys.exit(2)
|
|
1577
|
+
return "stdin", template
|
|
1578
|
+
return "inline", args.prompt
|
|
1579
|
+
|
|
1580
|
+
return None, None
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def render_prompt(task_id, prompt_template, file_format=""):
|
|
1584
|
+
"""Render prompt template placeholders for a task."""
|
|
1585
|
+
if not prompt_template:
|
|
1586
|
+
return ""
|
|
1587
|
+
rendered = prompt_template.replace("{{task_id}}", task_id)
|
|
1588
|
+
rendered = rendered.replace("{{item}}", _item_map.get(task_id, task_id))
|
|
1589
|
+
rendered = rendered.replace("{{file_format}}", file_format)
|
|
1590
|
+
return "\n\n---\n\n" + rendered
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def prepare_prompt_files(task_ids, prompt_template, prompt_dir, file_format=""):
|
|
1594
|
+
"""Materialize all per-task prompt files at startup for long-running resilience."""
|
|
1595
|
+
prompt_paths = {}
|
|
1596
|
+
prompt_dir.mkdir(parents=True, exist_ok=True)
|
|
1597
|
+
for task_id in task_ids:
|
|
1598
|
+
prompt_path = prompt_dir / f"prompt_{task_id}.txt"
|
|
1599
|
+
prompt_path.write_text(render_prompt(task_id, prompt_template, file_format), encoding="utf-8")
|
|
1600
|
+
prompt_paths[task_id] = str(prompt_path)
|
|
1601
|
+
return prompt_paths
|
|
1167
1602
|
|
|
1168
1603
|
|
|
1169
1604
|
def print_summary(task_ids, results, task_times, wall_elapsed, total_tasks):
|
|
@@ -1209,8 +1644,9 @@ def print_summary(task_ids, results, task_times, wall_elapsed, total_tasks):
|
|
|
1209
1644
|
# Headless mode
|
|
1210
1645
|
# ---------------------------------------------------------------------------
|
|
1211
1646
|
|
|
1212
|
-
def run_task(task_id, semaphore, pwd,
|
|
1213
|
-
service="claude", model=":sonnet", file_format="", strict=False
|
|
1647
|
+
def run_task(task_id, semaphore, pwd, prompt_path=None, output_dir=None,
|
|
1648
|
+
service="claude", model=":sonnet", file_format="", strict=False,
|
|
1649
|
+
subagent_args=None):
|
|
1214
1650
|
"""Run a single juno-code subprocess (called from its own thread)."""
|
|
1215
1651
|
global _completed_count
|
|
1216
1652
|
|
|
@@ -1222,37 +1658,28 @@ def run_task(task_id, semaphore, pwd, prompt_file_path=None, output_dir=None,
|
|
|
1222
1658
|
|
|
1223
1659
|
task_log_path = LOG_DIR / f"task_{task_id}.log"
|
|
1224
1660
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
try:
|
|
1229
|
-
extra = Path(prompt_file_path).read_text(encoding="utf-8")
|
|
1230
|
-
extra = extra.replace("{{task_id}}", task_id)
|
|
1231
|
-
extra = extra.replace("{{item}}", _item_map.get(task_id, task_id))
|
|
1232
|
-
extra = extra.replace("{{file_format}}", file_format)
|
|
1233
|
-
prompt += "\n\n---\n\n" + extra
|
|
1234
|
-
log_combined(f"Loaded prompt file ({len(extra)} chars)", task_id)
|
|
1235
|
-
except Exception as e:
|
|
1236
|
-
log_combined(f"WARNING: Could not read prompt file: {e}", task_id)
|
|
1237
|
-
|
|
1238
|
-
tmp_prompt_dir = Path("/tmp/pc-headless")
|
|
1239
|
-
tmp_prompt_dir.mkdir(parents=True, exist_ok=True)
|
|
1240
|
-
tmp_prompt_path = tmp_prompt_dir / f"prompt_{task_id}.txt"
|
|
1241
|
-
tmp_prompt_path.write_text(prompt, encoding="utf-8")
|
|
1661
|
+
if prompt_path and not Path(prompt_path).exists():
|
|
1662
|
+
log_combined("Prompt file missing at runtime; task cannot start", task_id)
|
|
1663
|
+
return task_id, 1
|
|
1242
1664
|
|
|
1243
1665
|
env = _build_process_env()
|
|
1244
1666
|
|
|
1667
|
+
cmd = [
|
|
1668
|
+
"juno-code",
|
|
1669
|
+
"-b", "shell",
|
|
1670
|
+
"-s", service,
|
|
1671
|
+
"-m", model,
|
|
1672
|
+
"-i", "1",
|
|
1673
|
+
"-v",
|
|
1674
|
+
"--no-hooks",
|
|
1675
|
+
]
|
|
1676
|
+
if subagent_args:
|
|
1677
|
+
cmd.extend(subagent_args)
|
|
1678
|
+
if prompt_path:
|
|
1679
|
+
cmd.extend(["-f", str(prompt_path)])
|
|
1680
|
+
|
|
1245
1681
|
proc = subprocess.Popen(
|
|
1246
|
-
|
|
1247
|
-
"juno-code",
|
|
1248
|
-
"-b", "shell",
|
|
1249
|
-
"-s", service,
|
|
1250
|
-
"-m", model,
|
|
1251
|
-
"-i", "1",
|
|
1252
|
-
"-v",
|
|
1253
|
-
"--no-hooks",
|
|
1254
|
-
"-f", str(tmp_prompt_path),
|
|
1255
|
-
],
|
|
1682
|
+
cmd,
|
|
1256
1683
|
stdout=subprocess.PIPE,
|
|
1257
1684
|
stderr=subprocess.STDOUT,
|
|
1258
1685
|
cwd=pwd,
|
|
@@ -1270,11 +1697,6 @@ def run_task(task_id, semaphore, pwd, prompt_file_path=None, output_dir=None,
|
|
|
1270
1697
|
proc.wait()
|
|
1271
1698
|
reader.join()
|
|
1272
1699
|
|
|
1273
|
-
try:
|
|
1274
|
-
tmp_prompt_path.unlink(missing_ok=True)
|
|
1275
|
-
except OSError:
|
|
1276
|
-
pass
|
|
1277
|
-
|
|
1278
1700
|
elapsed = time.monotonic() - start
|
|
1279
1701
|
|
|
1280
1702
|
with _completed_lock:
|
|
@@ -1312,7 +1734,8 @@ def run_task(task_id, semaphore, pwd, prompt_file_path=None, output_dir=None,
|
|
|
1312
1734
|
semaphore.release()
|
|
1313
1735
|
|
|
1314
1736
|
|
|
1315
|
-
def run_headless_mode(args, pwd,
|
|
1737
|
+
def run_headless_mode(args, pwd, prompt_source_label, prompt_template,
|
|
1738
|
+
output_dir, service, model, subagent_args=None):
|
|
1316
1739
|
"""Run tasks in headless mode using ThreadPoolExecutor."""
|
|
1317
1740
|
global _total_tasks
|
|
1318
1741
|
_total_tasks = len(args.kanban)
|
|
@@ -1329,8 +1752,11 @@ def run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model):
|
|
|
1329
1752
|
+ (f"\n ... and {len(args.kanban) - 3} more" if len(args.kanban) > 3 else ""))
|
|
1330
1753
|
log_combined(f"Parallelism: {args.parallel}")
|
|
1331
1754
|
log_combined(f"Service: {service} | Model: {model}")
|
|
1332
|
-
if
|
|
1333
|
-
log_combined(f"
|
|
1755
|
+
if subagent_args:
|
|
1756
|
+
log_combined(f"Subagent args: {' '.join(subagent_args)}")
|
|
1757
|
+
if prompt_source_label:
|
|
1758
|
+
source_type = "Prompt file" if prompt_source_label not in ("inline", "stdin") else "Prompt source"
|
|
1759
|
+
log_combined(f"{source_type}: {prompt_source_label} (materialized at startup)")
|
|
1334
1760
|
if output_dir:
|
|
1335
1761
|
log_combined(f"Output dir: {output_dir}")
|
|
1336
1762
|
legend = " ".join(f"{_color_for(tid)} {tid}" for tid in args.kanban)
|
|
@@ -1345,10 +1771,16 @@ def run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model):
|
|
|
1345
1771
|
file_format = getattr(args, "file_format", "") or ""
|
|
1346
1772
|
strict = getattr(args, "strict", False)
|
|
1347
1773
|
|
|
1774
|
+
prompt_paths = {}
|
|
1775
|
+
if prompt_template is not None:
|
|
1776
|
+
prompt_dir = _tmp_dir(_run_id) / "prompts"
|
|
1777
|
+
prompt_paths = prepare_prompt_files(args.kanban, prompt_template, prompt_dir, file_format)
|
|
1778
|
+
log_combined(f"Prebuilt {len(prompt_paths)} prompt files in {prompt_dir}")
|
|
1779
|
+
|
|
1348
1780
|
with ThreadPoolExecutor(max_workers=len(args.kanban)) as pool:
|
|
1349
1781
|
futures = {
|
|
1350
|
-
pool.submit(run_task, task_id, semaphore, pwd,
|
|
1351
|
-
service, model, file_format, strict): task_id
|
|
1782
|
+
pool.submit(run_task, task_id, semaphore, pwd, prompt_paths.get(task_id), output_dir,
|
|
1783
|
+
service, model, file_format, strict, subagent_args): task_id
|
|
1352
1784
|
for task_id in args.kanban
|
|
1353
1785
|
}
|
|
1354
1786
|
|
|
@@ -1384,6 +1816,7 @@ def run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model):
|
|
|
1384
1816
|
)
|
|
1385
1817
|
_print_output_summary(agg_result)
|
|
1386
1818
|
|
|
1819
|
+
shutil.rmtree(str(_tmp_dir(_run_id)), ignore_errors=True)
|
|
1387
1820
|
sys.exit(1 if failed > 0 else 0)
|
|
1388
1821
|
|
|
1389
1822
|
|
|
@@ -1409,6 +1842,8 @@ class TaskState:
|
|
|
1409
1842
|
sentinel_id: str = ""
|
|
1410
1843
|
start_time_iso: str = ""
|
|
1411
1844
|
end_time_iso: str = ""
|
|
1845
|
+
session_id: str = ""
|
|
1846
|
+
total_cost_usd: float = 0.0
|
|
1412
1847
|
|
|
1413
1848
|
|
|
1414
1849
|
@dataclass
|
|
@@ -1525,62 +1960,57 @@ def create_tmux_session(session_name, mode, num_workers, pwd):
|
|
|
1525
1960
|
# Tmux mode — command building & dispatch
|
|
1526
1961
|
# ---------------------------------------------------------------------------
|
|
1527
1962
|
|
|
1528
|
-
def write_runner_script(task_id, pwd,
|
|
1963
|
+
def write_runner_script(task_id, pwd, prompt_path, session_name_short,
|
|
1529
1964
|
output_dir=None, service="claude", model=":sonnet",
|
|
1530
|
-
file_format=""):
|
|
1531
|
-
"""Write
|
|
1532
|
-
prompt = ""
|
|
1533
|
-
|
|
1534
|
-
if prompt_file_path:
|
|
1535
|
-
try:
|
|
1536
|
-
extra = Path(prompt_file_path).read_text(encoding="utf-8")
|
|
1537
|
-
extra = extra.replace("{{task_id}}", task_id)
|
|
1538
|
-
extra = extra.replace("{{item}}", _item_map.get(task_id, task_id))
|
|
1539
|
-
extra = extra.replace("{{file_format}}", file_format)
|
|
1540
|
-
prompt += "\n\n---\n\n" + extra
|
|
1541
|
-
except Exception:
|
|
1542
|
-
pass
|
|
1543
|
-
|
|
1965
|
+
file_format="", subagent_args=None):
|
|
1966
|
+
"""Write bash runner script for a task."""
|
|
1544
1967
|
sentinel_id = uuid.uuid4().hex[:12]
|
|
1545
1968
|
|
|
1546
1969
|
tmp = _tmp_dir(session_name_short)
|
|
1547
1970
|
tmp.mkdir(parents=True, exist_ok=True)
|
|
1548
1971
|
|
|
1549
|
-
prompt_path = tmp / f"prompt_{task_id}.txt"
|
|
1550
|
-
prompt_path.write_text(prompt)
|
|
1551
|
-
|
|
1552
1972
|
env_exports = _generate_env_exports()
|
|
1553
1973
|
|
|
1554
1974
|
env_path = tmp / f"env_{task_id}.sh"
|
|
1555
1975
|
env_path.write_text(env_exports + "\n")
|
|
1556
1976
|
|
|
1977
|
+
subagent_args_shell = " ".join(shlex.quote(arg) for arg in (subagent_args or []))
|
|
1978
|
+
prompt_arg = f" -f {shlex.quote(str(prompt_path))}" if prompt_path else ""
|
|
1979
|
+
|
|
1557
1980
|
runner_path = tmp / f"run_{task_id}.sh"
|
|
1558
1981
|
runner_path.write_text(textwrap.dedent("""\
|
|
1559
1982
|
#!/bin/bash
|
|
1560
1983
|
source %(env_path)s
|
|
1561
1984
|
cd %(pwd)s
|
|
1562
|
-
juno-code -b shell -s %(service)s -m %(model)s -i 1 -v --no-hooks
|
|
1563
|
-
-f %(prompt_path)s
|
|
1985
|
+
juno-code -b shell -s %(service)s -m %(model)s -i 1 -v --no-hooks%(subagent_args)s%(prompt_arg)s
|
|
1564
1986
|
echo "___DONE_%(sentinel_id)s_${?}___"
|
|
1565
1987
|
""") % {
|
|
1566
1988
|
"env_path": shlex.quote(str(env_path)),
|
|
1567
1989
|
"pwd": shlex.quote(pwd),
|
|
1568
|
-
"
|
|
1990
|
+
"prompt_arg": prompt_arg,
|
|
1569
1991
|
"sentinel_id": sentinel_id,
|
|
1570
1992
|
"service": shlex.quote(service),
|
|
1571
1993
|
"model": shlex.quote(model),
|
|
1994
|
+
"subagent_args": f" {subagent_args_shell}" if subagent_args_shell else "",
|
|
1572
1995
|
})
|
|
1573
1996
|
|
|
1574
1997
|
return str(runner_path), sentinel_id
|
|
1575
1998
|
|
|
1576
1999
|
|
|
1577
|
-
def dispatch_task(worker, task_id, task_state, pwd,
|
|
2000
|
+
def dispatch_task(worker, task_id, task_state, pwd, prompt_paths,
|
|
1578
2001
|
session_name_short, output_dir=None, service="claude",
|
|
1579
|
-
model=":sonnet", file_format=""
|
|
2002
|
+
model=":sonnet", file_format="", subagent_args=None,
|
|
2003
|
+
prompt_template=None):
|
|
1580
2004
|
"""Send a task command to a worker's tmux pane/window."""
|
|
2005
|
+
prompt_path = prompt_paths.get(task_id)
|
|
2006
|
+
if prompt_path and not Path(prompt_path).exists():
|
|
2007
|
+
prompt_path_obj = Path(prompt_path)
|
|
2008
|
+
prompt_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
2009
|
+
prompt_path_obj.write_text(render_prompt(task_id, prompt_template, file_format), encoding="utf-8")
|
|
2010
|
+
|
|
1581
2011
|
runner_path, sentinel_id = write_runner_script(
|
|
1582
|
-
task_id, pwd,
|
|
1583
|
-
service, model, file_format)
|
|
2012
|
+
task_id, pwd, prompt_path, session_name_short, output_dir,
|
|
2013
|
+
service, model, file_format, subagent_args)
|
|
1584
2014
|
|
|
1585
2015
|
# FIX-003: Stop old pipe-pane explicitly before starting new one
|
|
1586
2016
|
tmux_run(["pipe-pane", "-t", worker.tmux_target], check=False)
|
|
@@ -1756,7 +2186,9 @@ def update_dashboard_file(task_states, workers, paused, wall_start, session_name
|
|
|
1756
2186
|
lines.append("")
|
|
1757
2187
|
lines.append("-" * 58)
|
|
1758
2188
|
elapsed = format_duration(now - wall_start)
|
|
2189
|
+
total_cost_usd = sum(t.total_cost_usd for t in task_states.values() if t.total_cost_usd > 0)
|
|
1759
2190
|
lines.append(f" Pending: {pending} | Running: {running} | Done: {done} | Failed: {failed} | Total: {total}")
|
|
2191
|
+
lines.append(f" Total Cost (USD): ${total_cost_usd:.6f}")
|
|
1760
2192
|
if total > 0:
|
|
1761
2193
|
completed = done + failed
|
|
1762
2194
|
remaining = pending + running
|
|
@@ -1777,7 +2209,11 @@ def update_dashboard_file(task_states, workers, paused, wall_start, session_name
|
|
|
1777
2209
|
lines.append(f" [{bar}] {pct}% ({completed}/{total}) Wall: {elapsed}{eta_str}")
|
|
1778
2210
|
lines.append("-" * 58)
|
|
1779
2211
|
|
|
1780
|
-
|
|
2212
|
+
# Write atomically so tmux dashboard readers never see a partially-written frame.
|
|
2213
|
+
dashboard_content = "\n".join(lines) + "\n"
|
|
2214
|
+
dashboard_tmp_path = dashboard_path.parent / f"{dashboard_path.name}.tmp"
|
|
2215
|
+
dashboard_tmp_path.write_text(dashboard_content, encoding="utf-8")
|
|
2216
|
+
os.replace(dashboard_tmp_path, dashboard_path)
|
|
1781
2217
|
update_tmux_status_bar(task_states, paused, wall_start, session_name)
|
|
1782
2218
|
|
|
1783
2219
|
|
|
@@ -1785,10 +2221,10 @@ def update_dashboard_file(task_states, workers, paused, wall_start, session_name
|
|
|
1785
2221
|
# Tmux mode — orchestration loop
|
|
1786
2222
|
# ---------------------------------------------------------------------------
|
|
1787
2223
|
|
|
1788
|
-
def orchestration_loop(task_states, workers, task_queue, pwd,
|
|
1789
|
-
wall_start, session_name_short, session_name,
|
|
2224
|
+
def orchestration_loop(task_states, workers, task_queue, pwd, prompt_paths,
|
|
2225
|
+
prompt_template, wall_start, session_name_short, session_name,
|
|
1790
2226
|
output_dir=None, service="claude", model=":sonnet",
|
|
1791
|
-
file_format="", strict=False):
|
|
2227
|
+
file_format="", strict=False, subagent_args=None):
|
|
1792
2228
|
"""Main orchestration loop — polls workers, dispatches tasks, updates dashboard."""
|
|
1793
2229
|
all_task_ids = list(task_states.keys())
|
|
1794
2230
|
pause_path = _pause_file(session_name_short)
|
|
@@ -1839,9 +2275,9 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
|
|
|
1839
2275
|
|
|
1840
2276
|
# FIX-002: Parse result from log, with capture-pane fallback
|
|
1841
2277
|
task_log_path = LOG_DIR / f"task_{task_id}.log"
|
|
1842
|
-
backend_result = _parse_result_from_log(task_log_path)
|
|
2278
|
+
backend_result = _parse_result_from_log(task_log_path)
|
|
1843
2279
|
|
|
1844
|
-
if
|
|
2280
|
+
if backend_result is None:
|
|
1845
2281
|
try:
|
|
1846
2282
|
scrollback = tmux_run([
|
|
1847
2283
|
"capture-pane", "-t", worker.tmux_target,
|
|
@@ -1850,11 +2286,13 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
|
|
|
1850
2286
|
if scrollback:
|
|
1851
2287
|
ansi_re = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
|
|
1852
2288
|
clean = ansi_re.sub('', scrollback)
|
|
1853
|
-
|
|
1854
|
-
backend_result = _parse_result_from_log(task_log_path)
|
|
2289
|
+
backend_result = _parse_result_from_text(clean)
|
|
1855
2290
|
except Exception:
|
|
1856
2291
|
pass
|
|
1857
2292
|
|
|
2293
|
+
ts.session_id = _extract_session_id(backend_result) or ""
|
|
2294
|
+
ts.total_cost_usd = _extract_total_cost_usd(backend_result) or 0.0
|
|
2295
|
+
|
|
1858
2296
|
if strict and file_format and output_dir:
|
|
1859
2297
|
exit_code = _extract_strict_output(
|
|
1860
2298
|
task_id, task_log_path, output_dir, file_format, exit_code,
|
|
@@ -1897,8 +2335,9 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
|
|
|
1897
2335
|
next_task_id = task_queue.popleft()
|
|
1898
2336
|
dispatch_task(
|
|
1899
2337
|
worker, next_task_id, task_states[next_task_id],
|
|
1900
|
-
pwd,
|
|
1901
|
-
service, model, file_format,
|
|
2338
|
+
pwd, prompt_paths, session_name_short, output_dir,
|
|
2339
|
+
service, model, file_format, subagent_args,
|
|
2340
|
+
prompt_template,
|
|
1902
2341
|
)
|
|
1903
2342
|
|
|
1904
2343
|
# Update dashboard
|
|
@@ -1974,12 +2413,27 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
|
|
|
1974
2413
|
f" Fastest task: {format_duration(fastest)}",
|
|
1975
2414
|
f" Slowest task: {format_duration(slowest)}",
|
|
1976
2415
|
])
|
|
2416
|
+
total_cost_usd = sum(ts.total_cost_usd for ts in task_states.values() if ts.total_cost_usd > 0)
|
|
1977
2417
|
summary_lines.extend([
|
|
2418
|
+
f" Total cost: ${total_cost_usd:.6f} USD",
|
|
1978
2419
|
f" Run ID: {_run_id}",
|
|
1979
2420
|
f" Per-task logs: {LOG_DIR}/task_<TASK_ID>.log",
|
|
1980
|
-
"=" * 60,
|
|
1981
2421
|
])
|
|
1982
2422
|
|
|
2423
|
+
session_rows = []
|
|
2424
|
+
for tid in all_task_ids:
|
|
2425
|
+
ts = task_states[tid]
|
|
2426
|
+
if not ts.session_id and ts.total_cost_usd <= 0:
|
|
2427
|
+
continue
|
|
2428
|
+
session_rows.append((tid, ts.session_id or "-", ts.total_cost_usd))
|
|
2429
|
+
|
|
2430
|
+
if session_rows:
|
|
2431
|
+
summary_lines.append(" Session summary:")
|
|
2432
|
+
for tid, sid, cost in session_rows:
|
|
2433
|
+
summary_lines.append(f" {tid}: session_id={sid}, cost=${cost:.6f}")
|
|
2434
|
+
|
|
2435
|
+
summary_lines.append("=" * 60)
|
|
2436
|
+
|
|
1983
2437
|
with open(COMBINED_LOG, "a") as f:
|
|
1984
2438
|
for line in summary_lines:
|
|
1985
2439
|
f.write(f"[{timestamp}] {line}\n")
|
|
@@ -2031,7 +2485,8 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
|
|
|
2031
2485
|
# Tmux mode — entry point
|
|
2032
2486
|
# ---------------------------------------------------------------------------
|
|
2033
2487
|
|
|
2034
|
-
def run_tmux_mode(args, pwd,
|
|
2488
|
+
def run_tmux_mode(args, pwd, prompt_source_label, prompt_template, output_dir,
|
|
2489
|
+
service, model, subagent_args=None):
|
|
2035
2490
|
"""Set up tmux session and run orchestrator."""
|
|
2036
2491
|
num_workers = args.parallel
|
|
2037
2492
|
mode = args.tmux
|
|
@@ -2060,7 +2515,7 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
|
|
|
2060
2515
|
except (ValueError, ProcessLookupError, PermissionError):
|
|
2061
2516
|
pass
|
|
2062
2517
|
|
|
2063
|
-
for f in [_dashboard_file(session_name_short), _pause_file(session_name_short), pid_path]:
|
|
2518
|
+
for f in [_dashboard_file(session_name_short), _pause_file(session_name_short), pid_path, *_legacy_session_state_files(session_name_short)]:
|
|
2064
2519
|
if f.exists():
|
|
2065
2520
|
f.unlink()
|
|
2066
2521
|
|
|
@@ -2080,8 +2535,11 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
|
|
|
2080
2535
|
f.write(f"[{timestamp}] ... and {len(args.kanban) - 3} more\n")
|
|
2081
2536
|
f.write(f"[{timestamp}] Parallelism: {num_workers}\n")
|
|
2082
2537
|
f.write(f"[{timestamp}] Service: {service} | Model: {model}\n")
|
|
2083
|
-
if
|
|
2084
|
-
f.write(f"[{timestamp}]
|
|
2538
|
+
if subagent_args:
|
|
2539
|
+
f.write(f"[{timestamp}] Subagent args: {' '.join(subagent_args)}\n")
|
|
2540
|
+
if prompt_source_label:
|
|
2541
|
+
source_type = "Prompt file" if prompt_source_label not in ("inline", "stdin") else "Prompt source"
|
|
2542
|
+
f.write(f"[{timestamp}] {source_type}: {prompt_source_label} (materialized at startup)\n")
|
|
2085
2543
|
if output_dir:
|
|
2086
2544
|
f.write(f"[{timestamp}] Output dir: {output_dir}\n")
|
|
2087
2545
|
f.write(f"[{timestamp}] {'=' * 60}\n")
|
|
@@ -2097,15 +2555,25 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
|
|
|
2097
2555
|
task_states[tid] = TaskState(task_id=tid)
|
|
2098
2556
|
task_queue = deque(args.kanban)
|
|
2099
2557
|
|
|
2558
|
+
file_format = getattr(args, "file_format", "") or ""
|
|
2559
|
+
prompt_paths = {}
|
|
2560
|
+
if prompt_template is not None:
|
|
2561
|
+
prompt_dir = _tmp_dir(session_name_short) / "prompts"
|
|
2562
|
+
prompt_paths = prepare_prompt_files(args.kanban, prompt_template, prompt_dir, file_format)
|
|
2563
|
+
|
|
2100
2564
|
wall_start = time.monotonic()
|
|
2101
2565
|
|
|
2102
2566
|
# Start coordinator dashboard
|
|
2567
|
+
# NOTE (PfU2s8): Clear BEFORE printing each frame.
|
|
2568
|
+
# If clear happens after `cat`, tmux can leave stale prompt/command tails
|
|
2569
|
+
# (e.g. parts of this dashboard_cmd string) stitched into dashboard rows.
|
|
2570
|
+
# Keep: printf '\033[H\033[J'; cat ...
|
|
2103
2571
|
pid_path_str = shlex.quote(str(pid_path))
|
|
2104
2572
|
dashboard_file_str = shlex.quote(str(_dashboard_file(session_name_short)))
|
|
2105
2573
|
dashboard_cmd = (
|
|
2106
2574
|
f"trap 'kill $(cat {pid_path_str}) 2>/dev/null; exit' INT; "
|
|
2107
|
-
f"while true; do printf '\\033[H'; cat {dashboard_file_str} 2>/dev/null "
|
|
2108
|
-
f"|| echo 'Waiting for dashboard...';
|
|
2575
|
+
f"while true; do printf '\\033[H\\033[J'; cat {dashboard_file_str} 2>/dev/null "
|
|
2576
|
+
f"|| echo 'Waiting for dashboard...'; sleep 2; done"
|
|
2109
2577
|
)
|
|
2110
2578
|
tmux_run(["send-keys", "-t", coordinator_target, dashboard_cmd, "Enter"])
|
|
2111
2579
|
|
|
@@ -2148,23 +2616,22 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
|
|
|
2148
2616
|
if not task_queue:
|
|
2149
2617
|
break
|
|
2150
2618
|
next_task_id = task_queue.popleft()
|
|
2151
|
-
file_format = getattr(args, "file_format", "") or ""
|
|
2152
2619
|
dispatch_task(
|
|
2153
2620
|
worker, next_task_id, task_states[next_task_id],
|
|
2154
|
-
pwd,
|
|
2155
|
-
service, model, file_format,
|
|
2621
|
+
pwd, prompt_paths, session_name_short, output_dir,
|
|
2622
|
+
service, model, file_format, subagent_args,
|
|
2623
|
+
prompt_template,
|
|
2156
2624
|
)
|
|
2157
2625
|
|
|
2158
2626
|
update_dashboard_file(task_states, workers, False, wall_start,
|
|
2159
2627
|
session_name_short, session_name)
|
|
2160
2628
|
|
|
2161
|
-
file_format = getattr(args, "file_format", "") or ""
|
|
2162
2629
|
strict = getattr(args, "strict", False)
|
|
2163
2630
|
exit_code = orchestration_loop(
|
|
2164
2631
|
task_states, workers, task_queue,
|
|
2165
|
-
pwd,
|
|
2632
|
+
pwd, prompt_paths, prompt_template, wall_start,
|
|
2166
2633
|
session_name_short, session_name, output_dir,
|
|
2167
|
-
service, model, file_format, strict,
|
|
2634
|
+
service, model, file_format, strict, subagent_args,
|
|
2168
2635
|
)
|
|
2169
2636
|
except Exception:
|
|
2170
2637
|
import traceback
|
|
@@ -2226,16 +2693,20 @@ def main():
|
|
|
2226
2693
|
_task_color_map[tid] = _TASK_COLORS[i % len(_TASK_COLORS)]
|
|
2227
2694
|
|
|
2228
2695
|
service, model = _resolve_service_model(args)
|
|
2229
|
-
|
|
2696
|
+
prompt_source_label, prompt_template = resolve_prompt_source(args, pwd)
|
|
2230
2697
|
output_dir = _resolve_output_dir(args)
|
|
2698
|
+
subagent_args = getattr(args, "subagent_args_list", [])
|
|
2231
2699
|
|
|
2232
2700
|
_log_base.mkdir(parents=True, exist_ok=True)
|
|
2701
|
+
removed_tmp = cleanup_stale_tmp_artifacts()
|
|
2702
|
+
if removed_tmp > 0:
|
|
2703
|
+
print(f"[parallel_runner] Removed {removed_tmp} stale tmp artifact(s) older than 48h")
|
|
2233
2704
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
2234
2705
|
|
|
2235
2706
|
if args.tmux:
|
|
2236
|
-
run_tmux_mode(args, pwd,
|
|
2707
|
+
run_tmux_mode(args, pwd, prompt_source_label, prompt_template, output_dir, service, model, subagent_args)
|
|
2237
2708
|
else:
|
|
2238
|
-
run_headless_mode(args, pwd,
|
|
2709
|
+
run_headless_mode(args, pwd, prompt_source_label, prompt_template, output_dir, service, model, subagent_args)
|
|
2239
2710
|
|
|
2240
2711
|
|
|
2241
2712
|
if __name__ == "__main__":
|