okstra 0.191.0 → 0.191.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-report-translate.py +29 -4
- package/runtime/prompts/lead/convergence.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/python/okstra_ctl/convergence_engine.py +7 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +11 -1
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -6
- package/runtime/python/okstra_token_usage/collect.py +10 -7
- package/runtime/python/okstra_token_usage/grok.py +72 -25
- package/runtime/validators/validate-run.py +6 -1
- package/runtime/validators/validate_session_conformance.py +38 -1
package/docs/architecture.md
CHANGED
|
@@ -938,7 +938,7 @@ Tokens used in each run are collected from lead/worker session transcripts and w
|
|
|
938
938
|
- Collection sources:
|
|
939
939
|
- Claude lead/workers: per-message `message.usage` in `~/.claude/projects/<cwd-as-dashes>/<sessionId>.jsonl` or `~/.claude/projects/<cwd-as-dashes>/<lead-session>/subagents/agent-a<worker-name>-<hash>.jsonl`. Worker names are recovered from nested-subagent filenames, and only the directory for the current run's `team-state.lead.sessionId` is counted.
|
|
940
940
|
- Codex CLI: final `total_token_usage.total_tokens` in `~/.codex/sessions/Y/M/D/rollout-*.jsonl` (`$CODEX_HOME/sessions` first, then `~/.agent/sessions`). An in-session codex lead (`entryMode: current-session`) opened its rollout before the run and keeps it after, so its cumulative snapshot is the whole session; the lead is counted as the sum of `last_token_usage` over the `token_count` events inside the run window (`codex_session_window_total`), and a session that started before the window still qualifies when it wrote inside it (`find_codex_sessions(active_before_start=True)`).
|
|
941
|
-
- Grok CLI: the
|
|
941
|
+
- Grok CLI: the sum of the `usage` records in `~/.grok/sessions/<percent-encoded cwd>/<sessionId>/updates.jsonl` — one record is one prompt's usage (`numTurns` model calls, input re-sent each call), not a running total, so the last record alone is only the last prompt. grok (and kimi) run *inside* the stage worktree, so the directory is encoded from the worktree path, not the project root. An in-session grok lead is counted over the records inside the run window (`grok_session_window_total`), and a session that started before the window still qualifies when it wrote inside it (`find_grok_sessions(active_before_start=True)`).
|
|
942
942
|
- Claude workers on a non-Claude host: the wrapper runs `claude -p --session-id <id>`, and the transcript is `~/.claude/projects/<cwd-as-dashes>/<id>.jsonl`, found by the id `workerDispatches[].sessionId` recorded at dispatch.
|
|
943
943
|
- **Attribution key is the dispatch ledger, not the roster row.** `workers[].promptPath` names one prompt — the first dispatch — and v2 lead events carry no `workerId`. Every re-dispatch (reverify, critic-gap, plan-verify, report-writer re-author) is a new wrapper run with its own prompt, `.status.json`, and transcript, and only `team-state.workerDispatches[]` lists them all (`dispatch_state.worker_dispatch_records`, keyed by `workerId` or the last segment of `assignmentRef`). The collector takes one wrapper window per record, unions the transcripts found under each record's `worktreePath` and the project root, and sums them; wall-clock is the sum of the wrapper windows. Observed before this rule (2026-09-08, a codex-host planning run): codex counted 1 of 5 sessions, grok 0 of 1, claude 0 of 7.
|
|
944
944
|
- Antigravity CLI: the `usage` snapshot in the wrapper `<prompt>.status.json` — the runner records the last `usage` the `agy` stream reported (`result.usage` on a run that closed). The CLI writes no transcript under the home directory, and the worker `.log` is the stream rewritten as readable lines, not stream-json.
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
import argparse
|
|
26
26
|
import hashlib
|
|
27
27
|
import json
|
|
28
|
+
import re
|
|
28
29
|
import os
|
|
29
30
|
import sys
|
|
30
31
|
from pathlib import Path
|
|
@@ -199,17 +200,31 @@ def _translation_block(rows: list[str]) -> str:
|
|
|
199
200
|
return "\n".join(rows).strip().replace("\\`", "`")
|
|
200
201
|
|
|
201
202
|
|
|
203
|
+
_T_HEADING_RE = re.compile(r"^#{1,6}\s+T-\d{3}\b")
|
|
204
|
+
|
|
205
|
+
|
|
202
206
|
def _translation_blocks(path: Path) -> list[str]:
|
|
203
207
|
text = path.read_text(encoding="utf-8")
|
|
204
208
|
blocks: list[str] = []
|
|
205
209
|
current: list[str] | None = None
|
|
206
|
-
for row in text.splitlines():
|
|
210
|
+
for number, row in enumerate(text.splitlines(), start=1):
|
|
211
|
+
# 헤딩 수준이 다른 `### T-NNN` 은 블록 경계로 안 보여 본문에 묻히고, 그러면
|
|
212
|
+
# 블록 수 불일치라는 엉뚱한 메시지가 났다(실측 2026-09-08: id 164개 전부
|
|
213
|
+
# 일치, 틀린 것은 `#` 개수뿐). 경계처럼 생긴 줄은 여기서 이름을 대고 거절한다.
|
|
214
|
+
if _T_HEADING_RE.match(row) and not row.startswith("## T-"):
|
|
215
|
+
raise SystemExit(
|
|
216
|
+
f"error: translation heading must be level 2 — line {number} is "
|
|
217
|
+
f"{row.split()[0]!r}, expected '## T-NNN' ({path.name})"
|
|
218
|
+
)
|
|
207
219
|
if row.startswith("## T-"):
|
|
208
220
|
if current is not None:
|
|
209
221
|
blocks.append(_translation_block(current))
|
|
210
222
|
expected = f"## T-{len(blocks) + 1:03d}"
|
|
211
223
|
if row != expected:
|
|
212
|
-
raise SystemExit(
|
|
224
|
+
raise SystemExit(
|
|
225
|
+
f"error: expected translation heading {expected} at line {number}, "
|
|
226
|
+
f"found {row!r} ({path.name})"
|
|
227
|
+
)
|
|
213
228
|
current = []
|
|
214
229
|
elif current is not None:
|
|
215
230
|
current.append(row)
|
|
@@ -232,8 +247,18 @@ def cmd_write(args: argparse.Namespace) -> int:
|
|
|
232
247
|
_validate_source_payload(source, expected, args.source_digest)
|
|
233
248
|
sources = expected["strings"]
|
|
234
249
|
translated = _translation_blocks(Path(args.translations))
|
|
235
|
-
if len(translated) != len(sources)
|
|
236
|
-
raise SystemExit(
|
|
250
|
+
if len(translated) != len(sources):
|
|
251
|
+
raise SystemExit(
|
|
252
|
+
f"error: translation blocks must match every T-NNN item — "
|
|
253
|
+
f"{len(translated)} blocks in {Path(args.translations).name}, "
|
|
254
|
+
f"{len(sources)} items in the translation source"
|
|
255
|
+
)
|
|
256
|
+
empty = [f"T-{index + 1:03d}" for index, value in enumerate(translated) if not value]
|
|
257
|
+
if empty:
|
|
258
|
+
raise SystemExit(
|
|
259
|
+
"error: translation blocks must match every T-NNN item — empty: "
|
|
260
|
+
+ ", ".join(empty[:10])
|
|
261
|
+
)
|
|
237
262
|
lang = expected["lang"]
|
|
238
263
|
sidecar = translation_sidecar_path(data_path, lang)
|
|
239
264
|
if sidecar.is_symlink():
|
|
@@ -89,7 +89,7 @@ Read the worker result files generated in Phase 4/5 and extract individual findi
|
|
|
89
89
|
- Same semantics but disjoint ticket sets → separate groups (do NOT over-merge across tickets).
|
|
90
90
|
- Only one worker confirms a finding → one single-source group.
|
|
91
91
|
4. When grouping is ambiguous, prefer splitting over merging (avoid over-merging). Semantic matching, ticket-set equality, and evidence interpretation remain lead judgments; the engine does not perform fuzzy matching or decide whether evidence is credible.
|
|
92
|
-
5. Author the fixed grouping Markdown accepted by `okstra convergence prepare-groups --run-manifest <run-manifest> --input <grouping.md>`, then run that command. Python owns the artifact identifier, target path, schema version, task identity, run-manifest reference, and every participant reference. Each Markdown group records ticket IDs, origin worker and evidence, discovering workers, source worker item IDs, and optional captured evidence. An analysis sidetrack with no ticket uses an empty `Tickets:` value, never a placeholder. Use the ordered functional roster: finding workers have the `analysis` audience, the report author has `report-writer`, and the lead uses `lead`. A lead source never votes. Never infer live evidence or functional scope from wording, provider, model, or execution label.
|
|
92
|
+
5. Author the fixed grouping Markdown accepted by `okstra convergence prepare-groups --run-manifest <run-manifest> --input <grouping.md>`, then run that command. Python owns the artifact identifier, target path, schema version, task identity, run-manifest reference, and every participant reference. Each Markdown group records ticket IDs, origin worker and evidence, discovering workers, source worker item IDs, and optional captured evidence. An analysis sidetrack with no ticket uses an empty `Tickets:` value, never a placeholder. Use the ordered functional roster: finding workers have the `analysis` audience, the report author has `report-writer`, and the lead uses `lead`. A lead source never votes. For `implementation` runs the convergence sources are the verifier-role results only — the executor's result is deliverable evidence, not a convergence source (**Enforced:** `_validate_worker_execution_identity` in `scripts/okstra_ctl/convergence_engine.py` rejects an `implementer` source with `analysis audience source role is not allowed`). Never infer live evidence or functional scope from wording, provider, model, or execution label.
|
|
93
93
|
|
|
94
94
|
The command sets each worker's paired `participantRef` and `sourceRoleExecutionRef` from the run manifest's canonical role state. It sets `sourceRoleExecutionRef` to the selected source `RoleExecution` row's `roleExecutionRef`, not that row's `sourceRoleExecutionRef` field.
|
|
95
95
|
6. Do not write a queue or classification in this grouped-input artifact. `okstra convergence seed` classifies Round 0 the same way in both modes: a group whose sources are **two or more distinct role executions** becomes `full-consensus` immediately, and only single-source groups enter the working queue. Independent co-derivation is already cross-verification — the adversarial burden of proof targets single-source claims, not a finding two roles reached on their own. A source is counted once per analysis worker, and one analysis worker is exactly one `sourceRoleExecutionRef` — the same identity the reverify roster uses for independence — so two roles held by one provider count as two and no role can count twice. **Enforced:** `_parse_workers` rejects a duplicate `workerId` and `_validate_worker_execution_identity` rejects a duplicate `sourceRoleExecutionRef`, both in `scripts/okstra_ctl/convergence_engine.py`. Semantic grouping merges provenance only; it does not decide a single-source finding is reliable. Section 6 never enters the grouped input.
|
|
@@ -146,7 +146,7 @@ Do NOT replace them with prose ("Now I'm starting Phase 2..."), do NOT skip a ch
|
|
|
146
146
|
|
|
147
147
|
`okstra-run` surfaces these lines to the user directly; `okstra lead-progress append` persists them in the selected adapter's declared conformance evidence/event source for post-hoc retrieval.
|
|
148
148
|
|
|
149
|
-
**Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. When the plan-body state file records two or more rounds, `_check_plan_verify_cleanup_checkpoints` additionally requires a `phase-5.5.9-plan-verify` line per round and a `phase-batch-cleanup` between consecutive rounds. For `implementation`, `_check_progress_checkpoints` additionally requires the `phase-5-stage` announcement once any worker is dispatched, and the `phase-5-stage-complete` line once an implementer worker completed. For activity-contract-v1 planning, `_check_activity_contract` validates the structured worker pairs, verification and self-fix counts, user-decision references, and `A-NNN` ordering. `phase-7-teardown` and `complete` fire after validation and are not checked.
|
|
149
|
+
**Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. When the plan-body state file records two or more rounds, `_check_plan_verify_cleanup_checkpoints` additionally requires a `phase-5.5.9-plan-verify` line per round and a `phase-batch-cleanup` between consecutive rounds. For `implementation`, `_check_progress_checkpoints` additionally requires the `phase-5-stage` announcement once any worker is dispatched, and the `phase-5-stage-complete` line once an implementer worker completed **and** this stage's carry sidecar exists (`_stage_carry_persisted`) — a stage whose carry was withheld (`FAIL` / non-result) is not asked for the line. For activity-contract-v1 planning, `_check_activity_contract` validates the structured worker pairs, verification and self-fix counts, user-decision references, and `A-NNN` ordering. `phase-7-teardown` and `complete` fire after validation and are not checked.
|
|
150
150
|
|
|
151
151
|
## Asking the user (BLOCKING)
|
|
152
152
|
|
|
@@ -2420,9 +2420,15 @@ def _validate_worker_execution_identity(
|
|
|
2420
2420
|
)
|
|
2421
2421
|
allowed_roles = _AUDIENCE_SOURCE_ROLES[worker["audience"]]
|
|
2422
2422
|
if source.role not in allowed_roles:
|
|
2423
|
+
hint = ""
|
|
2424
|
+
if source.role == "implementer":
|
|
2425
|
+
hint = (
|
|
2426
|
+
" — the executor's result is deliverable evidence, not a "
|
|
2427
|
+
"convergence source; group the verifier results instead"
|
|
2428
|
+
)
|
|
2423
2429
|
raise ConvergenceContractError(
|
|
2424
2430
|
f"{worker['audience']} audience source role is not allowed: "
|
|
2425
|
-
f"{source.role}"
|
|
2431
|
+
f"{source.role} (allowed: {', '.join(sorted(allowed_roles))}){hint}"
|
|
2426
2432
|
)
|
|
2427
2433
|
source_refs = [worker["sourceRoleExecutionRef"] for worker in workers]
|
|
2428
2434
|
if len(source_refs) != len(set(source_refs)):
|
|
@@ -631,7 +631,17 @@ def _dispatch_cli_wrapper_batch(
|
|
|
631
631
|
contract is not None and contract[1].mutation_audit == "batch"
|
|
632
632
|
for contract in contracts
|
|
633
633
|
)
|
|
634
|
-
|
|
634
|
+
# 재시도 라운드는 baseline 을 다시 찍는다. 쓰기 계약은 attempt 소유라
|
|
635
|
+
# (`_persisted_write_contract`) attempt 2 의 정책 digest 는 attempt 1 과
|
|
636
|
+
# 다른데, 첫 라운드의 baseline 을 그대로 쓰면 마감의
|
|
637
|
+
# `_validate_snapshot_authority` 가 `policyDigests` 불일치로 죽고 attempt-N
|
|
638
|
+
# 사이드카는 끝내 안 써진다(실측 2026-09-08, translator attempt 2:
|
|
639
|
+
# 산출물 완전·exit 0 인데 dispatcher 예외). 앞 라운드에서 완주한 동료의
|
|
640
|
+
# 변경은 그 attempt 의 감사가 이미 판정했으므로 새 baseline 에 흡수돼도
|
|
641
|
+
# 맞다 — 재시도는 `failed-no-mutation` 뒤에만 오므로(`retry_allowed`)
|
|
642
|
+
# 미해결 변경이 baseline 으로 세탁되지는 않는다.
|
|
643
|
+
retry_round = any(attempt > 1 for _job, attempt in prepared)
|
|
644
|
+
if needs_batch and (baseline_snapshot is None or retry_round):
|
|
635
645
|
baseline_snapshot = _mutation_snapshot(
|
|
636
646
|
plan, policies, tuple(job for job, _ in prepared)
|
|
637
647
|
)
|
|
@@ -281,12 +281,25 @@ def _validate_snapshot_authority(
|
|
|
281
281
|
policies: tuple[WritePolicy, ...],
|
|
282
282
|
) -> None:
|
|
283
283
|
expected = tuple(policy.digest for policy in policies)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
mismatches: list[str] = []
|
|
285
|
+
if before.root != after.root:
|
|
286
|
+
mismatches.append(f"root {before.root} != {after.root}")
|
|
287
|
+
if before.artifact_root != after.artifact_root:
|
|
288
|
+
mismatches.append(
|
|
289
|
+
f"artifactRoot {before.artifact_root} != {after.artifact_root}"
|
|
290
|
+
)
|
|
291
|
+
if before.policy_digests != expected:
|
|
292
|
+
mismatches.append(
|
|
293
|
+
"policyDigests snapshot=" + ",".join(before.policy_digests)
|
|
294
|
+
+ " policies=" + ",".join(expected)
|
|
295
|
+
)
|
|
296
|
+
if mismatches:
|
|
297
|
+
# 어느 비교가 어긋났는지 없는 예외는 원인 특정이 불가능했다(실측
|
|
298
|
+
# 2026-09-08: 배치 안 재시도가 attempt 1 의 스냅샷을 attempt 2 의 계약과
|
|
299
|
+
# 대조해 죽었는데, 메시지는 "does not match batch policies" 뿐이었다).
|
|
300
|
+
raise MutationAuditError(
|
|
301
|
+
"mutation snapshot does not match batch policies: " + "; ".join(mismatches)
|
|
302
|
+
)
|
|
290
303
|
|
|
291
304
|
|
|
292
305
|
def _maximum_precision(policy: WritePolicy) -> str:
|
|
@@ -32,6 +32,7 @@ from .grok import (
|
|
|
32
32
|
find_grok_sessions,
|
|
33
33
|
grok_session_is_non_interactive,
|
|
34
34
|
grok_session_total,
|
|
35
|
+
grok_session_window_total,
|
|
35
36
|
)
|
|
36
37
|
from .paths import claude_project_dir, find_session_jsonl, utc_now
|
|
37
38
|
from .pricing import antigravity_cost_usd, provider_cost_usd
|
|
@@ -644,13 +645,15 @@ def _cli_session_totals(
|
|
|
644
645
|
*,
|
|
645
646
|
window: tuple[str, str] | None = None,
|
|
646
647
|
) -> list[dict]:
|
|
647
|
-
"""세션별 합계. `window` 는
|
|
648
|
+
"""세션별 합계. `window` 는 리드 전용 — run 보다 먼저 열린 세션을 창으로 자른다."""
|
|
648
649
|
totals = []
|
|
649
650
|
for session_path in session_paths:
|
|
650
651
|
if provider == "codex" and window is not None:
|
|
651
652
|
total = codex_session_window_total(session_path, *window)
|
|
652
653
|
elif provider == "codex":
|
|
653
654
|
total = codex_session_total(session_path)
|
|
655
|
+
elif provider == "grok" and window is not None:
|
|
656
|
+
total = grok_session_window_total(session_path, *window)
|
|
654
657
|
elif provider == "antigravity":
|
|
655
658
|
total = (
|
|
656
659
|
antigravity_status_total(session_path)
|
|
@@ -1100,17 +1103,17 @@ def _cli_lead_usage(
|
|
|
1100
1103
|
if isinstance(worker, dict)
|
|
1101
1104
|
for path in ((worker.get("usage") or {}).get("cliSessionPaths") or [])
|
|
1102
1105
|
}
|
|
1103
|
-
# in-session codex
|
|
1104
|
-
# 세션만 보면 없다고 나오고, 세션 전체를 더하면 다른 task 의 턴이
|
|
1105
|
-
# 창 안에서 활동한 세션을 후보에 넣고 토큰은 창으로 잘라 센다.
|
|
1106
|
-
window = (run_since, run_until)
|
|
1106
|
+
# in-session 리드(codex·grok)는 run 보다 먼저 열린 세션이다 — 창 안에서
|
|
1107
|
+
# 시작한 세션만 보면 없다고 나오고, 세션 전체를 더하면 다른 task 의 턴이
|
|
1108
|
+
# 섞인다. 창 안에서 활동한 세션을 후보에 넣고 토큰은 창으로 잘라 센다.
|
|
1109
|
+
window = (run_since, run_until)
|
|
1107
1110
|
if provider == "codex":
|
|
1108
1111
|
candidates = find_codex_sessions(
|
|
1109
1112
|
project_root, run_since, run_until, active_before_start=True,
|
|
1110
1113
|
)
|
|
1111
1114
|
else:
|
|
1112
|
-
candidates =
|
|
1113
|
-
|
|
1115
|
+
candidates = find_grok_sessions(
|
|
1116
|
+
project_root, run_since, run_until, active_before_start=True,
|
|
1114
1117
|
)
|
|
1115
1118
|
sessions = _select_cli_lead_sessions(
|
|
1116
1119
|
provider,
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Grok Build session collectors.
|
|
2
2
|
|
|
3
3
|
공식 세션 문서는 대화를 ``~/.grok/sessions/`` 에 둔다. cwd 는 퍼센트 인코딩된
|
|
4
|
-
디렉터리 이름이다.
|
|
5
|
-
``
|
|
4
|
+
디렉터리 이름이다. ``updates.jsonl`` 의 ``params.update.usage`` 행 하나는
|
|
5
|
+
**프롬프트 한 번**의 사용량이다 — ``numTurns`` 는 그 프롬프트 안의 모델 호출
|
|
6
|
+
수이고 ``inputTokens`` 는 매번 컨텍스트 전체라 값이 오르내린다(실측
|
|
7
|
+
2026-09-08, 대화형 세션 3개 39행: 83k → 798k → 406k → 187k …). 세션 합계는
|
|
8
|
+
행의 합이고, 마지막 행은 마지막 프롬프트일 뿐이다. exec 래퍼 워커는 프롬프트가
|
|
9
|
+
하나라 행도 하나다.
|
|
6
10
|
"""
|
|
7
11
|
from __future__ import annotations
|
|
8
12
|
|
|
@@ -53,39 +57,55 @@ def _model_snapshot(usage: dict) -> tuple[dict, str | None]:
|
|
|
53
57
|
return usage, None
|
|
54
58
|
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
""
|
|
58
|
-
|
|
60
|
+
_SUM_KEYS = (
|
|
61
|
+
("totalTokens", "totalTokens"),
|
|
62
|
+
("inputTokens", "inputTokens"),
|
|
63
|
+
("outputTokens", "outputTokens"),
|
|
64
|
+
("cachedInputTokens", "cachedReadTokens"),
|
|
65
|
+
("reasoningOutputTokens", "reasoningTokens"),
|
|
66
|
+
("durationMs", "apiDurationMs"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def grok_session_window_total(
|
|
71
|
+
updates_path: Path, since: str | None = None, until: str | None = None,
|
|
72
|
+
) -> dict:
|
|
73
|
+
"""창 안 usage 행의 합. 창이 없으면 세션 전체.
|
|
74
|
+
|
|
75
|
+
한 행이 프롬프트 한 번의 사용량이므로 더한다 — 마지막 행만 읽으면 여러
|
|
76
|
+
프롬프트를 돌린 세션(in-session 리드)은 마지막 프롬프트만 남는다. 창은
|
|
77
|
+
run 보다 먼저 열린 리드 세션에서 다른 task 의 프롬프트를 걸러 낸다.
|
|
78
|
+
"""
|
|
79
|
+
sums = {name: 0 for name, _raw in _SUM_KEYS}
|
|
59
80
|
model: str | None = None
|
|
60
81
|
started: str | None = None
|
|
61
82
|
ended: str | None = None
|
|
83
|
+
counted = 0
|
|
62
84
|
for record in iter_jsonl(updates_path):
|
|
63
85
|
usage = _usage_payload(record)
|
|
64
86
|
if usage is None:
|
|
65
87
|
continue
|
|
66
88
|
iso = _iso_from_unix(record.get("timestamp"))
|
|
89
|
+
if iso and not ts_in_window(iso, since, until):
|
|
90
|
+
continue
|
|
91
|
+
snapshot, snapshot_model = _model_snapshot(usage)
|
|
92
|
+
for name, raw in _SUM_KEYS:
|
|
93
|
+
sums[name] += snapshot.get(raw, 0) or 0
|
|
94
|
+
if snapshot_model:
|
|
95
|
+
model = snapshot_model
|
|
67
96
|
if iso and started is None:
|
|
68
97
|
started = iso
|
|
69
98
|
if iso:
|
|
70
99
|
ended = iso
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if snapshot_model:
|
|
74
|
-
model = snapshot_model
|
|
75
|
-
if last is None:
|
|
100
|
+
counted += 1
|
|
101
|
+
if not counted:
|
|
76
102
|
return {"totalTokens": 0, "available": False}
|
|
77
|
-
return {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"durationMs": last.get("apiDurationMs", 0) or 0,
|
|
84
|
-
"model": model,
|
|
85
|
-
"startedAt": started,
|
|
86
|
-
"endedAt": ended,
|
|
87
|
-
"available": True,
|
|
88
|
-
}
|
|
103
|
+
return {**sums, "model": model, "startedAt": started, "endedAt": ended, "available": True}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def grok_session_total(updates_path: Path) -> dict:
|
|
107
|
+
"""세션 전체 — 모든 usage 행의 합."""
|
|
108
|
+
return grok_session_window_total(updates_path)
|
|
89
109
|
|
|
90
110
|
|
|
91
111
|
def _session_started_in_window(
|
|
@@ -102,14 +122,38 @@ def _session_started_in_window(
|
|
|
102
122
|
return False
|
|
103
123
|
|
|
104
124
|
|
|
125
|
+
def _session_active_in_window(
|
|
126
|
+
updates_path: Path, started_at: str, ended_at: str,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""창보다 먼저 열렸지만 창 안에도 usage 행이 있는 세션 — in-session 리드."""
|
|
129
|
+
first: str | None = None
|
|
130
|
+
for record in iter_jsonl(updates_path):
|
|
131
|
+
iso = _iso_from_unix(record.get("timestamp"))
|
|
132
|
+
if not iso:
|
|
133
|
+
continue
|
|
134
|
+
if first is None:
|
|
135
|
+
first = iso
|
|
136
|
+
if not ts_in_window(first, None, started_at):
|
|
137
|
+
return False
|
|
138
|
+
if ts_in_window(iso, started_at, ended_at):
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
105
143
|
def find_grok_sessions(
|
|
106
144
|
cwd: Path,
|
|
107
145
|
started_at: str,
|
|
108
146
|
ended_at: str,
|
|
109
147
|
*,
|
|
110
148
|
session_root: Path | None = None,
|
|
149
|
+
active_before_start: bool = False,
|
|
111
150
|
) -> list[Path]:
|
|
112
|
-
"""cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl.
|
|
151
|
+
"""cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl.
|
|
152
|
+
|
|
153
|
+
`active_before_start=True` 는 창보다 먼저 시작했지만 창 안에서도 프롬프트를
|
|
154
|
+
돌린 세션을 더한다 — in-session 리드의 모양이고, 토큰은
|
|
155
|
+
`grok_session_window_total` 이 창으로 잘라 센다.
|
|
156
|
+
"""
|
|
113
157
|
if not started_at or not ended_at:
|
|
114
158
|
return []
|
|
115
159
|
root = session_root or grok_sessions_root()
|
|
@@ -125,8 +169,11 @@ def find_grok_sessions(
|
|
|
125
169
|
continue
|
|
126
170
|
for session in child.iterdir():
|
|
127
171
|
updates = session / "updates.jsonl"
|
|
128
|
-
if updates.is_file()
|
|
129
|
-
|
|
172
|
+
if not updates.is_file():
|
|
173
|
+
continue
|
|
174
|
+
if _session_started_in_window(updates, started_at, ended_at) or (
|
|
175
|
+
active_before_start
|
|
176
|
+
and _session_active_in_window(updates, started_at, ended_at)
|
|
130
177
|
):
|
|
131
178
|
matches.append(updates)
|
|
132
179
|
return sorted(matches)
|
|
@@ -569,7 +569,12 @@ def _validate_agent_dispatch_contract(
|
|
|
569
569
|
dispatch_id = str(link.get("dispatchId") or "").strip()
|
|
570
570
|
result_path = str(link.get("resultPath") or "").strip()
|
|
571
571
|
if dispatch_id not in ids or not result_path:
|
|
572
|
-
|
|
572
|
+
# 같은 문장이 링크 수만큼 반복되면 어느 링크인지 알 수 없다 — 대상을 이름한다.
|
|
573
|
+
failures.append(
|
|
574
|
+
"agent result link has no matching dispatch record: "
|
|
575
|
+
f"dispatchId={dispatch_id or '<empty>'} "
|
|
576
|
+
f"resultPath={Path(result_path).name if result_path else '<empty>'}"
|
|
577
|
+
)
|
|
573
578
|
continue
|
|
574
579
|
if uses_v2_identity:
|
|
575
580
|
dispatch = ids[dispatch_id]
|
|
@@ -35,6 +35,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
|
|
|
35
35
|
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
36
36
|
sys.path.insert(0, str(_ssot_dir))
|
|
37
37
|
|
|
38
|
+
from okstra_ctl.paths import RunRef # noqa: E402
|
|
38
39
|
from okstra_ctl.worker_heartbeat import ( # noqa: E402
|
|
39
40
|
HEARTBEAT_LINE_RE,
|
|
40
41
|
IN_STAGE_PREFIX,
|
|
@@ -1521,6 +1522,42 @@ def _check_plan_verify_cleanup_checkpoints(
|
|
|
1521
1522
|
)
|
|
1522
1523
|
|
|
1523
1524
|
|
|
1525
|
+
_STAGE_ANNOUNCE_RE = re.compile(r"\bstage=(\d+)\b")
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _announced_stage(by_phase: Mapping[str, list[tuple[str, str]]]) -> int | None:
|
|
1529
|
+
for _ts, line in by_phase.get("phase-5-stage", []):
|
|
1530
|
+
matched = _STAGE_ANNOUNCE_RE.search(line)
|
|
1531
|
+
if matched:
|
|
1532
|
+
return int(matched.group(1))
|
|
1533
|
+
return None
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _stage_carry_persisted(
|
|
1537
|
+
run_dir: Path, by_phase: Mapping[str, list[tuple[str, str]]]
|
|
1538
|
+
) -> bool:
|
|
1539
|
+
"""이 stage 의 carry 사이드카가 디스크에 있는가 — 완료 라인의 전제.
|
|
1540
|
+
|
|
1541
|
+
`phase-5-stage-complete` 는 executor 의 `### Stage Carry Evidence` 를 파싱한
|
|
1542
|
+
뒤에만 낼 수 있고, 계약은 executor 가 carry 증거 없이 끝나면(FAIL 또는
|
|
1543
|
+
non-result) 그 라인을 생략하라고 한다. 그런데 검사는 명부의 `completed`
|
|
1544
|
+
만 봐서, 계약대로 생략한 리드가 권고를 받았다(실측 2026-09-08,
|
|
1545
|
+
fontsninja-v3-site dev-10627-2 implementation stage-1: executor 가 상위
|
|
1546
|
+
게이트 부재로 carry 를 의도적으로 보류). FAIL 이면 사이드카를 쓰지 않는다는
|
|
1547
|
+
같은 계약을 `validate-run.py` `_validate_stage_carry_sidecar_exists` 가
|
|
1548
|
+
집행하므로, 그 파일의 존재를 완료 라인의 조건으로 쓴다. stage 번호는 run
|
|
1549
|
+
디렉터리(`stage-<N>`)에서, 없으면 `phase-5-stage stage=<N>` 공지에서 읽는다.
|
|
1550
|
+
"""
|
|
1551
|
+
try:
|
|
1552
|
+
ref = RunRef.from_run_dir(run_dir)
|
|
1553
|
+
except ValueError:
|
|
1554
|
+
return True
|
|
1555
|
+
stage = ref.stage if ref.stage is not None else _announced_stage(by_phase)
|
|
1556
|
+
if stage is None:
|
|
1557
|
+
return any(ref.carry_dir.glob("stage-*.json"))
|
|
1558
|
+
return ref.carry(stage).exists()
|
|
1559
|
+
|
|
1560
|
+
|
|
1524
1561
|
def _check_progress_checkpoints(
|
|
1525
1562
|
evidence: _LeadEvidence,
|
|
1526
1563
|
team_state: dict,
|
|
@@ -1605,7 +1642,7 @@ def _check_progress_checkpoints(
|
|
|
1605
1642
|
)
|
|
1606
1643
|
require(
|
|
1607
1644
|
"phase-5-stage-complete",
|
|
1608
|
-
implementer_completed,
|
|
1645
|
+
implementer_completed and _stage_carry_persisted(run_dir, by_phase),
|
|
1609
1646
|
"immediately after parsing the Executor's `### Stage Carry Evidence` "
|
|
1610
1647
|
"block — `stage=<N> steps=<done>/<count>` from its stepResults",
|
|
1611
1648
|
)
|