okstra 0.190.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 +6 -2
- 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/adapters/hosts/codex/relay.md +15 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +11 -5
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -2
- 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/dispatch_state.py +27 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -6
- package/runtime/python/okstra_ctl/wizard/engine.py +22 -1
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +75 -0
- package/runtime/python/okstra_ctl/wizard/state.py +2 -0
- package/runtime/python/okstra_token_usage/codex.py +87 -2
- package/runtime/python/okstra_token_usage/collect.py +227 -49
- package/runtime/python/okstra_token_usage/grok.py +72 -25
- package/runtime/python/okstra_token_usage/pricing.py +8 -4
- package/runtime/skills/okstra-run/SKILL.md +6 -2
- package/runtime/validators/validate-run.py +6 -1
- package/runtime/validators/validate_session_conformance.py +38 -1
package/docs/architecture.md
CHANGED
|
@@ -937,11 +937,15 @@ Tokens used in each run are collected from lead/worker session transcripts and w
|
|
|
937
937
|
- Helper CLI: `scripts/okstra-token-usage.py`
|
|
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
|
-
- Codex CLI: final `total_token_usage.total_tokens` in `~/.
|
|
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 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
|
+
- 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
|
+
- **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.
|
|
941
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.
|
|
942
945
|
- CLI execution evidence and token attribution are independent. A wrapper `.status.json` proves `not-started`, `started`, `exited`, `timeout`, or `failed` and supplies the worker's collection window; only a matching transcript with a final token snapshot — or, for a provider without a home transcript, the `usage` snapshot the runner wrote into that `.status.json` — proves attributable usage. If a wrapper exited successfully but no attributable transcript exists, the worker remains `source: "unavailable"` with `cliExecutionStatus: "exited"` and a reason instead of becoming zero usage or being described as never invoked.
|
|
943
946
|
- Records billable-equivalent token math and USD cost estimates. It applies Anthropic billing ratios (`cache_creation_5m=1.25x`, `cache_creation_1h=2.0x`, `cache_read=0.1x`, `output=5x`). When the transcript provides separate `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` values, they are counted separately.
|
|
944
|
-
- Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py
|
|
947
|
+
- Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py` (Claude and Gemini rate tables) and in each provider catalog's `ModelSpec(pricing=...)` (codex, grok, kimi — merged through `catalog_pricing`). Update it when model prices change. Model IDs that fail price matching are exposed to the user in `usageSummary.unmatchedModels`, preventing silent-zero incidents.
|
|
948
|
+
- **Cost is always the public list price, regardless of how the account is billed.** The report answers "how much was consumed", not "what the invoice says": a model served under a ChatGPT/Claude subscription is still priced at its API rate, so every selectable catalog row must carry a price. The one row without a price is `codex-auto-review`, for which no public rate exists.
|
|
945
949
|
- Project-wide historical usage is exposed through the read-only `okstra usage-report` command (`scripts/okstra_ctl/usage_report.py`) and the `okstra-usage` skill. It defaults to the whole current project's last 30 days and returns run coverage, raw and billable-equivalent tokens, known USD cost, CPU-sum milliseconds, and wall-clock milliseconds grouped by task type. Runs without usable Phase 7 usage are excluded from resource totals and reported through unavailable reason counts rather than treated as zero usage; unmatched model names remain visible when their tokens and time are included but their cost is not. Use `okstra-inspect` for one task's elapsed/context detail and `okstra-rollup` for task-group or project status/report digests.
|
|
946
950
|
- **Incremental scan cache (P6)**: To avoid rescanning session jsonl files, a per-file byte cursor and the extracted usage events before windowing are stored in `$OKSTRA_HOME/cache/token-usage/<transcript-dir>/<sessionId>.json` (`scripts/okstra_token_usage/cursor.py`). The run window (since/until) is reevaluated over events on every invocation, so even if a rerun narrows the window, the total matches a full scan. The cache is derived data; identifier mismatch, truncation, or corruption automatically falls back to a full rescan, and `okstra-token-usage.py --no-cache` forces a bypass.
|
|
947
951
|
- **Phase timeline (P0 instrumentation)**: The collector extracts `PROGRESS: phase-*` checkpoint lines (see "Progress reporting" in prompts/lead/okstra-lead-contract.md) from the lead session jsonl scoped to the run window, and records them in team-state as a `phaseTimeline` block (`{source, phases: [{phase, firstAt, lastAt, markerCount, wallMsToNext}]}`) (`scripts/okstra_token_usage/collect.py :: phase_timeline`). This provides measurement points for per-phase wall-clock time within the run and is consumed by the "Per-run phase breakdown" in the `okstra-inspect` time facet. Runs without markers explicitly report that measurement is unavailable with `phases: []`.
|
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
|
|
|
@@ -115,11 +115,25 @@ For `request_user_input`, send one to three questions. Each question carries `id
|
|
|
115
115
|
For a `host-text` mapping, render each numbered item as its option label followed by its description verbatim; preserve every item and its order. The next user message is the raw answer: do not translate a number such as `1`, a CSV reply such as `1, 3`, an option label, or an option value before `okstra wizard step`. For `sequential-group`, collect one raw reply per question in order and build one compact JSON object keyed by the corresponding `questions[].step`; the wizard owns all normalization.
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
### Runtime-generated selectable screens
|
|
119
|
+
|
|
120
|
+
`wizard/engine.py` adapts choice screens before returning `next` when the session declares `native_single_select`. `wizard/picker_navigation.py` preserves all original choices while paging long lists, collecting multi-selection through toggle/complete choices, and disambiguating duplicate labels. Oversized or unsupported groups are presented one member at a time. These paths are exercised by `tests/domain/wizard/test_picker_navigation.py` and `test_role_model_selection.py`.
|
|
121
|
+
|
|
122
|
+
Render exactly the returned current screen using its `interaction.kind`, including navigation and completion options. Submit their original values through `okstra wizard step` like other options. The runtime keeps navigation and partial selections in the state file and does not submit the underlying workflow decision until selection is complete. Do not reconstruct the full model list, perform pagination yourself, or replace model choices with a request to type `provider/model`. A repeated step ID after navigation or a toggle is the next screen, not a duplicated question; render that newly returned screen once.
|
|
123
|
+
|
|
124
|
+
If the runtime still returns a numbered interaction when the user requires a selector, preserve the state and check installation and live capabilities. Do not invent a text-input exception. Text steps remain text steps; a direct-input choice is selected through the picker before the runtime asks for the custom value.
|
|
125
|
+
|
|
126
|
+
### Plan decisions and execution permissions
|
|
127
|
+
|
|
128
|
+
Classify the requested action, not the word "approval". The wizard's `approve_plan_confirm` is a workflow decision about adopting the selected plan; it is a `pick` prompt built by `_build_approve_plan_confirm` in `wizard/steps_plan.py`. Render its existing options through the available native selector, just like task type and plan selection. Explain the effect of each option in the question tool and map the selected label back to its original value (`yes`, `no`, or `yes_apply` when offered). Recording that decision in okstra artifacts does not turn the question into a host permission request. Do not ask the user to type an approval word or switch to prose merely because this step is called approval.
|
|
129
|
+
|
|
130
|
+
Host permission requests concern execution privileges, sandbox escalation, or access to a protected resource. Use the host's permission mechanism for those requests; a wizard answer does not grant those privileges. If a subsequent command requires such permission, handle it separately at that command.
|
|
131
|
+
|
|
118
132
|
### Client-aware question tool selection
|
|
119
133
|
|
|
120
134
|
Before intersecting `semanticFunctions` with live capabilities, prefer `request_user_input` when the current session permits that call. Use its live restrictions rather than assuming it is always Plan-only: Codex CLI can enable it in Default mode with `default_mode_request_user_input`. The JSON mapping above remains the terminal picker contract.
|
|
121
135
|
|
|
122
|
-
Use `request_user_input_async` only when the current client explicitly supports interactive asynchronous question cards, such as the Codex desktop app, and the synchronous tool is unavailable. Callable does not mean selectable: the Codex terminal (`codex-tui`, session source `cli`) can accept an asynchronous question but render it as a plain bulleted agent message. Do not count that terminal delivery as `native_single_select` or `native_question_group`. For a supported desktop client, replace the `function` of both native entries above with `request_user_input_async` and use the mapping below. Keep the
|
|
136
|
+
Use `request_user_input_async` only when the current client explicitly supports interactive asynchronous question cards, such as the Codex desktop app, and the synchronous tool is unavailable. Callable does not mean selectable: the Codex terminal (`codex-tui`, session source `cli`) can accept an asynchronous question but render it as a plain bulleted agent message. Do not count that terminal delivery as `native_single_select` or `native_question_group`. For a supported desktop client, replace the `function` of both native entries above with `request_user_input_async` and use the mapping below. Keep the declared `nativeLimits`; the runtime generates the selectable screens within those limits.
|
|
123
137
|
|
|
124
138
|
If the user requested a selectable interface in the terminal and `request_user_input` is unavailable, keep the current wizard step pending instead of printing its options. `okstra install` enables `features.default_mode_request_user_input` when a Codex home exists, through `ensureCodexQuestionPicker` in `src/lib/host-config.mts`. Restart or resume the Codex session after installation and reread this relay. Changing the feature does not replace the current session's tool catalog. Preserve the existing wizard state-file path and call `okstra wizard step --state-file <existing-path> --no-submit` after resuming; do not initialize a replacement wizard or submit a guessed answer. If installation reports a configuration shape it cannot safely edit, its recovery command is `codex features enable default_mode_request_user_input`. Apply a configuration change only when authorized by the user, otherwise present the recovery command. The feature's availability is reported by `codex features list`; older clients without it need a client update or a mode in which the synchronous tool is permitted.
|
|
125
139
|
|
|
@@ -21,11 +21,17 @@ import okstra_ctl.model_discovery as model_discovery
|
|
|
21
21
|
# `gpt-5.6` is not a slug that catalog offers at all, so it is gone from here;
|
|
22
22
|
# its rate moved to `_LEGACY_CODEX_PRICING` so past runs still price.
|
|
23
23
|
CODEX = {
|
|
24
|
-
#
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
# 비용은 계정이 구독이든 API 든 공개 API 단가(입력·캐시 입력·출력 USD/1M)로
|
|
25
|
+
# 추정한다 — 리포트가 답하는 것은 "얼마나 썼는가" 이지 "청구서에 얼마가
|
|
26
|
+
# 찍히는가" 가 아니다. 단가를 비우면 그 워커의 비용이 `--` 로 빠지고, 접두어
|
|
27
|
+
# 폴백(`_LEGACY_CODEX_PRICING`)에 걸리면 terra·luna 가 sol 단가로 과대
|
|
28
|
+
# 계상된다(실측 2026-09-08). 출처: OpenAI 표준 등급, 2026-09 기준
|
|
29
|
+
# (morphllm.com/openai-api-pricing, cloudzero.com/blog/openai-pricing,
|
|
30
|
+
# layer3labs.io/guides/gpt-6-astra-api-pricing).
|
|
31
|
+
"gpt-6-astra": ModelSpec("gpt-6-astra", "gpt-6-astra", "gpt-6-astra", pricing=(10.0, 1.0, 50.0)),
|
|
32
|
+
"gpt-5.6-sol": ModelSpec("gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", pricing=(5.0, 0.50, 30.0)),
|
|
33
|
+
"gpt-5.6-terra": ModelSpec("gpt-5.6-terra", "gpt-5.6-terra", "gpt-5.6-terra", pricing=(2.0, 0.20, 12.0)),
|
|
34
|
+
"gpt-5.6-luna": ModelSpec("gpt-5.6-luna", "gpt-5.6-luna", "gpt-5.6-luna", pricing=(0.20, 0.02, 1.20)),
|
|
29
35
|
# picker 에서는 감춘다. 엔트리는 남긴다 — 과거 run 의 토큰 사용량을
|
|
30
36
|
# 정산할 때 pricing 을 이 표에서 찾는다.
|
|
31
37
|
"gpt-5.4-mini": ModelSpec("gpt-5.4-mini", "gpt-5.4-mini", "gpt-5.4-mini", pricing=(0.75, 0.075, 4.50), selectable=False),
|
|
@@ -19,9 +19,12 @@ from okstra_ctl.domain.worker_stream import content_block_events
|
|
|
19
19
|
KIMI = {
|
|
20
20
|
"kimi-k3": ModelSpec("kimi-k3", "Kimi K3", "kimi-k3", aliases=("kimi k3",), pricing=(3.00, 0.30, 15.00)),
|
|
21
21
|
# picker 에서는 감춘다: kimi-k3 와 표시명·실체가 같아 목록에 두 줄로 뜬다.
|
|
22
|
-
|
|
22
|
+
# 단가는 kimi-k3 와 같다 — 같은 모델의 다른 슬러그이고, `_match_pricing` 은
|
|
23
|
+
# `kimi-k3` 키를 `k3` 관측값에 맞추지 못하므로 행마다 값을 둔다. k3-256k 는
|
|
24
|
+
# Moonshot 이 별도 단가를 공개하지 않아 K3 단가를 쓴다(2026-09-08 확인).
|
|
25
|
+
"k3": ModelSpec("k3", "Kimi K3", "k3", pricing=(3.00, 0.30, 15.00), selectable=False),
|
|
23
26
|
# picker 에서는 감춘다(사유는 codex 의 gpt-5.4-mini 와 동일).
|
|
24
|
-
"k3-256k": ModelSpec("k3-256k", "Kimi K3 256K", "k3-256k", aliases=("kimi k3 256k",), selectable=False),
|
|
27
|
+
"k3-256k": ModelSpec("k3-256k", "Kimi K3 256K", "k3-256k", aliases=("kimi k3 256k",), pricing=(3.00, 0.30, 15.00), selectable=False),
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
|
|
@@ -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
|
)
|
|
@@ -1692,16 +1692,39 @@ def worker_session_ids(
|
|
|
1692
1692
|
here, the same way `add_observed_session` keeps it for the keys it appends to.
|
|
1693
1693
|
"""
|
|
1694
1694
|
session_ids: list[str] = []
|
|
1695
|
+
for record in worker_dispatch_records(team_state, worker_id):
|
|
1696
|
+
session_id = str(record.get("sessionId") or "").strip()
|
|
1697
|
+
if session_id and session_id not in session_ids:
|
|
1698
|
+
session_ids.append(session_id)
|
|
1699
|
+
return session_ids
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def worker_dispatch_records(
|
|
1703
|
+
team_state: Mapping[str, Any], worker_id: str | None = None
|
|
1704
|
+
) -> list[Mapping[str, Any]]:
|
|
1705
|
+
"""한 워커(또는 run 전체)의 `workerDispatches[]` 행을 기록된 순서대로.
|
|
1706
|
+
|
|
1707
|
+
사용량 수집이 워커의 실행 증거를 찾는 열쇠는 이 행들이다 — 프롬프트 경로
|
|
1708
|
+
(status 사이드카 → 래퍼 창), 세션 id(claude 트랜스크립트), 워크트리 경로
|
|
1709
|
+
(grok·kimi 는 그 안에서 돌아 세션 디렉터리가 그 경로로 인코딩된다).
|
|
1710
|
+
`workers[]` 행의 `promptPath` 는 첫 dispatch 하나만 가리키므로 재검증·
|
|
1711
|
+
critic-gap·plan-verify 로 다시 띄운 세션은 거기서 보이지 않는다(실측
|
|
1712
|
+
2026-09-08 jobs implementation-planning r01: codex 5회 dispatch 중 1회만
|
|
1713
|
+
집계, v2 명부 행 `scope` 는 `promptPath` 가 비어 0회).
|
|
1714
|
+
|
|
1715
|
+
v1 행은 `workerId`, v2 행은 `assignmentRef` 마지막 마디로 워커에 묶인다
|
|
1716
|
+
(`_dispatch_worker_key`). 리스트가 아니면 빈 결과 — `worker_session_ids`
|
|
1717
|
+
와 같은 이유로 호출자를 깨지 않는다.
|
|
1718
|
+
"""
|
|
1695
1719
|
dispatches = team_state.get("workerDispatches")
|
|
1720
|
+
records: list[Mapping[str, Any]] = []
|
|
1696
1721
|
for record in dispatches if isinstance(dispatches, list) else []:
|
|
1697
1722
|
if not isinstance(record, Mapping):
|
|
1698
1723
|
continue
|
|
1699
1724
|
if worker_id is not None and _dispatch_worker_key(record) != worker_id:
|
|
1700
1725
|
continue
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
session_ids.append(session_id)
|
|
1704
|
-
return session_ids
|
|
1726
|
+
records.append(record)
|
|
1727
|
+
return records
|
|
1705
1728
|
|
|
1706
1729
|
|
|
1707
1730
|
def _dispatch_worker_key(record: Mapping[str, Any]) -> str:
|
|
@@ -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:
|
|
@@ -33,6 +33,7 @@ from .ids import (
|
|
|
33
33
|
)
|
|
34
34
|
from .state import Prompt, WizardError, WizardState, _is_role_selection_step
|
|
35
35
|
from .prompts import _domain_prompt
|
|
36
|
+
from .picker_navigation import present_picker, accept_picker_answer
|
|
36
37
|
from .roles import _submit_role_prompt, next_role_prompt
|
|
37
38
|
from .steps_identity import _submit_task_pick
|
|
38
39
|
from .steps_analysis import _advance_design_prep_item
|
|
@@ -125,11 +126,24 @@ def next_prompt(state: WizardState) -> Prompt:
|
|
|
125
126
|
for _ in range(len(STEPS)):
|
|
126
127
|
prompt = _next_prompt_screen(state)
|
|
127
128
|
if not _is_lone_free_input_pick(prompt):
|
|
128
|
-
return prompt
|
|
129
|
+
return _native_picker_screen(state, prompt)
|
|
129
130
|
_take_free_input_branch(state, prompt)
|
|
130
131
|
return _next_prompt_screen(state)
|
|
131
132
|
|
|
132
133
|
|
|
134
|
+
def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
|
|
135
|
+
if "native_single_select" not in state.available_functions:
|
|
136
|
+
return prompt
|
|
137
|
+
if prompt.kind == "pick_group":
|
|
138
|
+
if _interaction_plan(state, prompt).kind == "native-group":
|
|
139
|
+
return prompt
|
|
140
|
+
prompt = prompt.questions[0]
|
|
141
|
+
if _interaction_plan(state, prompt).kind == "native-multi":
|
|
142
|
+
return prompt
|
|
143
|
+
limit = default_host_registry().resolve(state.host_runtime).interaction().native_option_limit
|
|
144
|
+
return present_picker(state, prompt, limit=limit)
|
|
145
|
+
|
|
146
|
+
|
|
133
147
|
def _next_prompt_screen(state: WizardState) -> Prompt:
|
|
134
148
|
if state.aborted:
|
|
135
149
|
return Prompt(step=S_ABORTED, kind="aborted")
|
|
@@ -345,6 +359,13 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
345
359
|
return {"echo": "", "next": prompt_payload(state, prompt)}
|
|
346
360
|
plan = _interaction_plan(state, prompt)
|
|
347
361
|
value = _normalize_interaction_answer(state, prompt, plan, value)
|
|
362
|
+
if plan.kind == "native-single":
|
|
363
|
+
original = _next_prompt_screen(state)
|
|
364
|
+
if original.kind == "pick_group":
|
|
365
|
+
original = next(q for q in original.questions if q.step == prompt.step)
|
|
366
|
+
value = accept_picker_answer(state, original, value)
|
|
367
|
+
if value is None:
|
|
368
|
+
return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
|
|
348
369
|
if prompt.kind == "pick_group":
|
|
349
370
|
return _submit_group(state, prompt, value)
|
|
350
371
|
if _is_role_selection_step(prompt.step):
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""호스트 선택기 한도 안에서 원래 선택지를 보존하는 화면 변환."""
|
|
2
|
+
from dataclasses import replace
|
|
3
|
+
|
|
4
|
+
from okstra_ctl.wizard_stage_intent import WHOLE_TASK_STAGE
|
|
5
|
+
|
|
6
|
+
from .ids import ALL_STAGES, PICK_TYPE_CUSTOM
|
|
7
|
+
from .state import Option, Prompt, WizardError, WizardState
|
|
8
|
+
|
|
9
|
+
_PAGE_PREFIX = "__okstra_picker_page__:"
|
|
10
|
+
_DONE = "__okstra_picker_done__"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
|
|
14
|
+
if prompt.kind != "pick" or not prompt.options:
|
|
15
|
+
return prompt
|
|
16
|
+
selected = state.picker_selected.get(prompt.step, [])
|
|
17
|
+
labels = [option.label for option in prompt.options]
|
|
18
|
+
options = [replace(
|
|
19
|
+
option,
|
|
20
|
+
label=("✓ " if option.value in selected else "") + option.label
|
|
21
|
+
+ (f" [{option.value}]" if labels.count(option.label) > 1 else ""),
|
|
22
|
+
) for option in prompt.options]
|
|
23
|
+
reserve = 2 if prompt.multi else 1
|
|
24
|
+
paged = len(options) + int(prompt.multi) > limit
|
|
25
|
+
size = max(1, limit - reserve) if paged else len(options)
|
|
26
|
+
offset = state.picker_offsets.get(prompt.step, 0)
|
|
27
|
+
offset = offset if 0 <= offset < len(options) else 0
|
|
28
|
+
shown = options[offset:offset + size]
|
|
29
|
+
label = prompt.label
|
|
30
|
+
if paged:
|
|
31
|
+
next_offset = offset + size if offset + size < len(options) else 0
|
|
32
|
+
shown.append(Option(
|
|
33
|
+
f"{_PAGE_PREFIX}{next_offset}",
|
|
34
|
+
"다음 선택지" if next_offset else "처음 선택지로",
|
|
35
|
+
))
|
|
36
|
+
label += f" ({offset + 1}–{min(offset + size, len(options))}/{len(options)})"
|
|
37
|
+
if prompt.multi:
|
|
38
|
+
shown.append(Option(_DONE, f"선택 완료 ({len(selected)}개)"))
|
|
39
|
+
label += " · 항목을 선택하면 선택/해제됩니다. 완료를 누르면 제출합니다."
|
|
40
|
+
if len(shown) == 1:
|
|
41
|
+
shown.append(Option(f"{_PAGE_PREFIX}0", "다시 보기"))
|
|
42
|
+
shown = [o for o in shown if o.value != PICK_TYPE_CUSTOM] + [
|
|
43
|
+
o for o in shown if o.value == PICK_TYPE_CUSTOM
|
|
44
|
+
]
|
|
45
|
+
return replace(prompt, label=label, options=shown, multi=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def accept_picker_answer(state: WizardState, prompt: Prompt, value: str) -> str | None:
|
|
49
|
+
if value.startswith(_PAGE_PREFIX):
|
|
50
|
+
raw_offset = value.removeprefix(_PAGE_PREFIX)
|
|
51
|
+
if not raw_offset.isdecimal() or not 0 <= int(raw_offset) < len(prompt.options):
|
|
52
|
+
raise WizardError("invalid picker page; select a returned navigation option")
|
|
53
|
+
state.picker_offsets[prompt.step] = int(raw_offset)
|
|
54
|
+
return None
|
|
55
|
+
if not prompt.multi:
|
|
56
|
+
state.picker_offsets.pop(prompt.step, None)
|
|
57
|
+
return value
|
|
58
|
+
if value != _DONE and value not in {o.value for o in prompt.options}:
|
|
59
|
+
raise WizardError("invalid picker choice; select an option from the current screen")
|
|
60
|
+
if value in {_DONE, PICK_TYPE_CUSTOM}:
|
|
61
|
+
selected = state.picker_selected.pop(prompt.step, [])
|
|
62
|
+
state.picker_offsets.pop(prompt.step, None)
|
|
63
|
+
return (
|
|
64
|
+
",".join(o.value for o in prompt.options if o.value in selected)
|
|
65
|
+
if value == _DONE else value
|
|
66
|
+
)
|
|
67
|
+
selected = state.picker_selected.setdefault(prompt.step, [])
|
|
68
|
+
if value in selected:
|
|
69
|
+
selected.remove(value)
|
|
70
|
+
elif value in {ALL_STAGES, WHOLE_TASK_STAGE}:
|
|
71
|
+
selected[:] = [value]
|
|
72
|
+
else:
|
|
73
|
+
selected[:] = [v for v in selected if v not in {ALL_STAGES, WHOLE_TASK_STAGE}]
|
|
74
|
+
selected.append(value)
|
|
75
|
+
return None
|
|
@@ -42,6 +42,8 @@ class WizardState:
|
|
|
42
42
|
host_runtime: str = "claude-code"
|
|
43
43
|
host_entry_mode: str = "current-session"
|
|
44
44
|
available_functions: list[str] = field(default_factory=list)
|
|
45
|
+
picker_offsets: dict[str, int] = field(default_factory=dict)
|
|
46
|
+
picker_selected: dict[str, list[str]] = field(default_factory=dict)
|
|
45
47
|
|
|
46
48
|
# task identity
|
|
47
49
|
is_new_task: Optional[bool] = None
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from .jsonl_io import iter_jsonl
|
|
8
9
|
from .paths import CODEX_SESSIONS, codex_session_roots, ts_in_window
|
|
@@ -50,6 +51,67 @@ def codex_session_total(jsonl_path: Path) -> dict:
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def codex_session_window_total(jsonl_path: Path, since: str, until: str) -> dict:
|
|
55
|
+
"""창 안에서 이 세션이 쓴 토큰 — token_count 이벤트의 `last_token_usage` 합.
|
|
56
|
+
|
|
57
|
+
in-session 리드(`entryMode: current-session`)의 rollout 은 run 보다 먼저
|
|
58
|
+
열려 다른 task 도 돌린 세션이라 마지막 `total_token_usage` 스냅샷은 세션
|
|
59
|
+
전체다. 누적치는 이벤트마다 `last_token_usage` 만큼 늘고 컨텍스트 압축
|
|
60
|
+
뒤 되돌아가므로(실측 2026-09-08 jobs 리드 세션: 601 이벤트 중 2회 감소),
|
|
61
|
+
창 끝·시작 누적치의 차가 아니라 창 안 이벤트의 `last_token_usage` 를
|
|
62
|
+
더한다. `last_token_usage` 가 없는 옛 기록은 직전 누적치와의 차(0 이상)로
|
|
63
|
+
센다. 창 안 이벤트가 없으면 `available: False`.
|
|
64
|
+
"""
|
|
65
|
+
keys = (
|
|
66
|
+
("totalTokens", "total_tokens"),
|
|
67
|
+
("inputTokens", "input_tokens"),
|
|
68
|
+
("cachedInputTokens", "cached_input_tokens"),
|
|
69
|
+
("outputTokens", "output_tokens"),
|
|
70
|
+
("reasoningOutputTokens", "reasoning_output_tokens"),
|
|
71
|
+
)
|
|
72
|
+
sums = {name: 0 for name, _raw in keys}
|
|
73
|
+
previous: dict = {}
|
|
74
|
+
model_val: str | None = None
|
|
75
|
+
cwd_val: str | None = None
|
|
76
|
+
started: str | None = None
|
|
77
|
+
first: str | None = None
|
|
78
|
+
last: str | None = None
|
|
79
|
+
for rec in iter_jsonl(jsonl_path):
|
|
80
|
+
kind = rec.get("type")
|
|
81
|
+
payload = rec.get("payload") or {}
|
|
82
|
+
if kind == "session_meta":
|
|
83
|
+
cwd_val = payload.get("cwd")
|
|
84
|
+
started = payload.get("timestamp")
|
|
85
|
+
elif kind == "turn_context":
|
|
86
|
+
if model_val is None and payload.get("model"):
|
|
87
|
+
model_val = payload["model"]
|
|
88
|
+
elif kind == "event_msg" and payload.get("type") == "token_count":
|
|
89
|
+
info = payload.get("info") or {}
|
|
90
|
+
cumulative = info.get("total_token_usage") or {}
|
|
91
|
+
turn = info.get("last_token_usage")
|
|
92
|
+
timestamp = str(rec.get("timestamp") or "")
|
|
93
|
+
if timestamp and ts_in_window(timestamp, since, until):
|
|
94
|
+
for name, raw in keys:
|
|
95
|
+
if isinstance(turn, dict):
|
|
96
|
+
sums[name] += turn.get(raw, 0) or 0
|
|
97
|
+
else:
|
|
98
|
+
sums[name] += max(
|
|
99
|
+
0, (cumulative.get(raw, 0) or 0) - (previous.get(raw, 0) or 0)
|
|
100
|
+
)
|
|
101
|
+
if first is None:
|
|
102
|
+
first = timestamp
|
|
103
|
+
last = timestamp
|
|
104
|
+
previous = cumulative
|
|
105
|
+
if first is None:
|
|
106
|
+
return {"totalTokens": 0, "cwd": cwd_val, "model": model_val, "available": False}
|
|
107
|
+
# 창 안에서 열린 세션은 세션 시작이 곧 창 안 활동의 시작이다. 창보다 먼저
|
|
108
|
+
# 열린 세션(in-session 리드)은 창 안 첫 이벤트부터 센다.
|
|
109
|
+
if started and ts_in_window(started, since, until):
|
|
110
|
+
first = started
|
|
111
|
+
return {**sums, "cwd": cwd_val, "model": model_val,
|
|
112
|
+
"startedAt": first, "endedAt": last, "available": True}
|
|
113
|
+
|
|
114
|
+
|
|
53
115
|
def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None:
|
|
54
116
|
"""Find the latest codex rollout jsonl in the requested window."""
|
|
55
117
|
sessions = find_codex_sessions(cwd, started_at, ended_at)
|
|
@@ -104,14 +166,32 @@ def codex_session_ids(path: Path) -> set[str]:
|
|
|
104
166
|
return {item for item in ids if item}
|
|
105
167
|
|
|
106
168
|
|
|
169
|
+
def _modified_in_window(path: Path, started_at: str, until: str) -> bool:
|
|
170
|
+
try:
|
|
171
|
+
mtime = path.stat().st_mtime
|
|
172
|
+
except OSError:
|
|
173
|
+
return False
|
|
174
|
+
modified = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
175
|
+
return ts_in_window(modified, started_at, None)
|
|
176
|
+
|
|
177
|
+
|
|
107
178
|
def find_codex_sessions(
|
|
108
179
|
cwd: Path,
|
|
109
180
|
started_at: str,
|
|
110
181
|
ended_at: str,
|
|
111
182
|
*,
|
|
112
183
|
session_roots: tuple[Path, ...] | None = None,
|
|
184
|
+
active_before_start: bool = False,
|
|
113
185
|
) -> list[Path]:
|
|
114
|
-
"""Find codex rollout jsonls whose meta.cwd matches the window.
|
|
186
|
+
"""Find codex rollout jsonls whose meta.cwd matches the window.
|
|
187
|
+
|
|
188
|
+
기본은 창 안에서 *시작한* 세션이다 — exec 래퍼 워커는 dispatch 마다 새
|
|
189
|
+
rollout 을 열므로 그것으로 충분하다. `active_before_start=True` 는 창보다
|
|
190
|
+
먼저 시작했지만 창 안에서도 기록이 이어진 세션(파일 mtime 이 창 시작 이후)을
|
|
191
|
+
더한다. in-session 리드가 그 모양이다: 세션은 run 보다 먼저 태어났으니
|
|
192
|
+
시작 시각으로 거르면 `no host session started in the run window` 로 빠지고,
|
|
193
|
+
토큰은 `codex_session_window_total` 이 창으로 잘라 센다.
|
|
194
|
+
"""
|
|
115
195
|
if not started_at or not ended_at:
|
|
116
196
|
return []
|
|
117
197
|
if session_roots is None:
|
|
@@ -137,7 +217,12 @@ def find_codex_sessions(
|
|
|
137
217
|
if session_cwd != target_cwd:
|
|
138
218
|
continue
|
|
139
219
|
if not ts_in_window(ts, started_at, ended_at):
|
|
140
|
-
|
|
220
|
+
if not (
|
|
221
|
+
active_before_start
|
|
222
|
+
and ts_in_window(ts, None, started_at)
|
|
223
|
+
and _modified_in_window(p, started_at, ended_at)
|
|
224
|
+
):
|
|
225
|
+
continue
|
|
141
226
|
candidates.append((ts, p))
|
|
142
227
|
if not candidates:
|
|
143
228
|
return []
|