okstra 0.169.0 → 0.169.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/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-error-log.py +38 -282
- package/runtime/prompts/lead/convergence.md +53 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +86 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +8 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +226 -6
- package/runtime/python/okstra_ctl/error_log_write.py +308 -0
- package/runtime/python/okstra_ctl/worker_audit_check.py +26 -4
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +59 -9
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +24 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +2 -2
package/docs/cli.md
CHANGED
|
@@ -774,7 +774,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
|
|
|
774
774
|
| `okstra codex-run <args…>` | Codex lead-adapter dry-run entry point. Accepts the same arguments as `render-bundle` but owns `--render-only --lead-runtime codex`. It prepares the task bundle and prints the prompt for the Codex lead without dispatching workers |
|
|
775
775
|
| `okstra worker-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Provider-neutral deterministic dispatcher for `runner=cli-wrapper` assignments. It verifies each adjacent invocation specification against the immutable run manifest immediately before process creation and records `core-pre-dispatch`; native-session rows stay with the host. The default selects CLI analysis assignments only. Phase 6 uses explicit `--workers report-writer`, and a mixed analysis/report batch is rejected. `--dry-run` performs the same verification and resolution without starting a provider process. |
|
|
776
776
|
| `okstra codex-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Compatibility alias for `okstra worker-dispatch`; it no longer selects a Codex-only transport-agent path. |
|
|
777
|
-
| `okstra agent-prompt materialize\|verify\|record-dispatch\|link-result\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. |
|
|
777
|
+
| `okstra agent-prompt materialize\|verify\|record-dispatch\|link-result\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. A published prompt is immutable, so re-running `materialize` with an edited instruction file fails as `existing_invocation_conflict`; `--replace-undispatched` is the one exit, for a prompt that failed a pre-dispatch gate and therefore ran nowhere. It republishes prompt and metadata together, and it is verified rather than trusted — a row in `agentDispatches` or `workerDispatches` naming this `invocationId` refuses the replacement and names the dispatch that used it. |
|
|
778
778
|
| `okstra team dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--jobs-file <path>] [--dry-run]` / `okstra team await --project-root <dir> --run-manifest <path> [--json]` / `okstra team teardown --project-root <dir> --run-manifest <path> [--dry-run] [--json]` | Read a `leadRuntime=external` run manifest and dispatch, await, or tear down tmux-pane workers. Default dispatch excludes report writer; Phase 6 selects it explicitly, and mixed analysis/report jobs are rejected. If a tmux pane cannot be created, gracefully degrade to the CLI wrapper and record the fallback in `workerDispatches[].degradedFrom` |
|
|
779
779
|
| `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run the whole Phase 7 post-report sequence in its contractual order: `token-usage` → `render-views` → `spawn-followups` → `validate-run`. Stops at the first non-zero exit and names the failing step, then prints a per-step `[ok]` / `[FAIL]` / `[skip]` summary on stderr so the outcome is legible without parsing the JSON payload. Every step is idempotent, so re-running after a fix is safe — but `--only <step>` (repeatable) reruns just the named steps in contractual order, which matters because `validate-run` is the step that usually fails and retrying it otherwise repeats the three steps before it at full token and wall-clock cost. This is the same code path (`scripts/okstra_ctl/report_finalize.py`) the Codex lead adapter runs automatically after its report-writer completes, so a Claude-led and a Codex-led run finalize identically. `--workspace-root` is owned by the Node wrapper. Prefer this over invoking the four steps individually |
|
|
780
780
|
| `okstra render-views <final-report.data.json\|final-report.md>` | The Phase 7 `render-views` step, runnable on its own. Schema v2 data is rendered directly (contract: `schemas/final-report-v2.0.schema.json`) into an always-generated, task-specific human HTML sibling while `templates/reports/final-report-v2.template.md` independently owns the AI handoff Markdown. Passing the Markdown sibling locates the same v2 data.json. Schema v1 and quick reports keep the legacy conditional renderer. The Node wrapper calls `scripts/okstra-render-report-views.py`; `validators/validate-report-views.py` verifies source/schema/template digests, required human fields, form controls, external assets, diagram/table ID parity, and Response ID parity |
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -1,296 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""OKSTRA error log helper.
|
|
2
|
+
"""OKSTRA error log helper — CLI adapter.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
The writer itself lives in `okstra_ctl.error_log_write`, which the deterministic
|
|
5
|
+
dispatcher also calls in-process. This file is the argparse surface over it and
|
|
6
|
+
re-exports the writer's names so the contract tests keep reading one module.
|
|
5
7
|
"""
|
|
6
8
|
from __future__ import annotations
|
|
7
9
|
|
|
8
10
|
import argparse
|
|
9
|
-
import datetime as dt
|
|
10
11
|
import json
|
|
11
|
-
import os
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
ALLOWED_ERROR_TYPES
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
CAUSE_EVIDENCE_FIELDS = ("targetProbe", "controlProbe")
|
|
31
|
-
# Both probes share a record's PIPE_BUF_BYTES budget with stderrExcerpt, so
|
|
32
|
-
# they cannot reuse the 2048 cap that assumes stderrExcerpt owns it alone.
|
|
33
|
-
CAUSE_PROBE_MAX_BYTES = 256
|
|
34
|
-
# Backstop vocabulary: scanned in `message` only — never in stderrExcerpt,
|
|
35
|
-
# where a kernel's real "Operation not permitted" is legitimate content.
|
|
36
|
-
# Deliberately excludes bare "blocked"/"blocks": everyday English that would
|
|
37
|
-
# reject honest records like "test blocked on upstream dependency".
|
|
38
|
-
_BLOCKING_CLAIM_TERMS = (
|
|
39
|
-
"sandbox", "not permitted", "permission denied", "eperm",
|
|
14
|
+
from okstra_ctl.error_log_write import (
|
|
15
|
+
ALLOWED_AGENT_ROLES,
|
|
16
|
+
ALLOWED_AGENTS,
|
|
17
|
+
ALLOWED_CAUSES,
|
|
18
|
+
ALLOWED_ERROR_TYPES,
|
|
19
|
+
CAUSE_EVIDENCE_FIELDS,
|
|
20
|
+
CAUSE_PROBE_MAX_BYTES,
|
|
21
|
+
PIPE_BUF_BYTES,
|
|
22
|
+
STDERR_EXCERPT_MAX_BYTES,
|
|
23
|
+
SUPPORTED_SIDECAR_SCHEMA_VERSIONS,
|
|
24
|
+
TRUNCATION_SUFFIX,
|
|
25
|
+
append_jsonl_line,
|
|
26
|
+
append_observed,
|
|
27
|
+
dump_from_worker_sidecar,
|
|
28
|
+
normalize_cause_context,
|
|
29
|
+
truncate_stderr,
|
|
40
30
|
)
|
|
41
|
-
# The backstop targets *unclassified* blocking claims. A worker that declared
|
|
42
|
-
# a specific cause has already done the honest work — `auth-failed` legitimately
|
|
43
|
-
# reads "permission denied" (MySQL 1045).
|
|
44
|
-
_UNCLASSIFIED_CAUSES = (None, "unknown")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _now_utc():
|
|
48
|
-
return dt.datetime.now(dt.timezone.utc)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _iso(t):
|
|
52
|
-
return t.isoformat()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _truncate_utf8(s, limit):
|
|
56
|
-
"""Truncate to `limit` bytes without splitting a multibyte character."""
|
|
57
|
-
if s is None:
|
|
58
|
-
return None
|
|
59
|
-
encoded = s.encode("utf-8")
|
|
60
|
-
if len(encoded) <= limit:
|
|
61
|
-
return s
|
|
62
|
-
cut = encoded[:limit]
|
|
63
|
-
while cut:
|
|
64
|
-
try:
|
|
65
|
-
return cut.decode("utf-8") + TRUNCATION_SUFFIX
|
|
66
|
-
except UnicodeDecodeError:
|
|
67
|
-
cut = cut[:-1]
|
|
68
|
-
return TRUNCATION_SUFFIX
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def truncate_stderr(s):
|
|
72
|
-
"""Truncate stderr text to STDERR_EXCERPT_MAX_BYTES, multibyte-safe."""
|
|
73
|
-
return _truncate_utf8(s, STDERR_EXCERPT_MAX_BYTES)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def normalize_cause_context(context, *, message):
|
|
77
|
-
"""Validate a record's cause claim and return the normalized context.
|
|
78
|
-
|
|
79
|
-
Raises ValueError on three conditions:
|
|
80
|
-
- `cause` is set to a value outside ALLOWED_CAUSES;
|
|
81
|
-
- `cause` is 'sandbox-denied' but the two probes are missing or blank —
|
|
82
|
-
those probes are what distinguish a real denial from an unreachable
|
|
83
|
-
or auth-gated target, the misdiagnosis this gate exists to stop;
|
|
84
|
-
- `message` asserts a block in prose while the record left its cause
|
|
85
|
-
unclassified, which would smuggle the same claim past the gate.
|
|
86
|
-
"""
|
|
87
|
-
cause = context.get("cause") if isinstance(context, dict) else None
|
|
88
|
-
|
|
89
|
-
if cause is not None and cause not in ALLOWED_CAUSES:
|
|
90
|
-
raise ValueError(
|
|
91
|
-
f"invalid cause: {cause!r} (allowed: {sorted(ALLOWED_CAUSES)})"
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
if cause == "sandbox-denied":
|
|
95
|
-
evidence = context.get("causeEvidence")
|
|
96
|
-
if not isinstance(evidence, dict):
|
|
97
|
-
raise ValueError(
|
|
98
|
-
"cause 'sandbox-denied' requires context.causeEvidence with "
|
|
99
|
-
f"{list(CAUSE_EVIDENCE_FIELDS)}"
|
|
100
|
-
)
|
|
101
|
-
normalized_evidence = {}
|
|
102
|
-
for field in CAUSE_EVIDENCE_FIELDS:
|
|
103
|
-
value = evidence.get(field)
|
|
104
|
-
if not isinstance(value, str) or not value.strip():
|
|
105
|
-
raise ValueError(
|
|
106
|
-
f"cause 'sandbox-denied' requires a non-empty "
|
|
107
|
-
f"context.causeEvidence.{field}: record the command and "
|
|
108
|
-
f"its raw output that proves the claim"
|
|
109
|
-
)
|
|
110
|
-
normalized_evidence[field] = _truncate_utf8(
|
|
111
|
-
value, CAUSE_PROBE_MAX_BYTES
|
|
112
|
-
)
|
|
113
|
-
return {**context, "causeEvidence": normalized_evidence}
|
|
114
31
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
With ``O_APPEND`` and a single ``write()`` syscall, the kernel
|
|
134
|
-
appends the entire payload as one indivisible operation as long as
|
|
135
|
-
the payload size is at most ``PIPE_BUF`` (4096 bytes on Linux and
|
|
136
|
-
macOS). Larger payloads may be split across syscalls and interleave
|
|
137
|
-
with concurrent writers, so this helper rejects them with
|
|
138
|
-
``ValueError`` rather than silently losing atomicity.
|
|
139
|
-
|
|
140
|
-
The atomicity contract holds only on POSIX filesystems with O_APPEND
|
|
141
|
-
semantics. Concurrent writers using ``O_TRUNC``, ``unlink``, or
|
|
142
|
-
non-append modes against the same path break the contract and are
|
|
143
|
-
out of scope for this helper.
|
|
144
|
-
|
|
145
|
-
Caller responsibilities:
|
|
146
|
-
- Keep records small (this module's stderr excerpt cap of
|
|
147
|
-
``STDERR_EXCERPT_MAX_BYTES`` exists to keep records well under
|
|
148
|
-
``PIPE_BUF_BYTES``).
|
|
149
|
-
- Handle ``TypeError`` from ``json.dumps`` for non-serializable values.
|
|
150
|
-
|
|
151
|
-
Creates parent directories as needed.
|
|
152
|
-
"""
|
|
153
|
-
p = Path(path)
|
|
154
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
-
# ensure_ascii=False keeps UTF-8 compact (no \uXXXX escapes).
|
|
156
|
-
# json.dumps escapes literal newlines inside string values, so the
|
|
157
|
-
# only unescaped newline is the record separator we append below.
|
|
158
|
-
line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
159
|
-
data = line.encode("utf-8")
|
|
160
|
-
if len(data) > PIPE_BUF_BYTES:
|
|
161
|
-
raise ValueError(
|
|
162
|
-
f"record too large for atomic append: {len(data)} bytes > "
|
|
163
|
-
f"PIPE_BUF ({PIPE_BUF_BYTES})"
|
|
164
|
-
)
|
|
165
|
-
# mode 0o644: owner read/write, group/world read-only.
|
|
166
|
-
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
167
|
-
try:
|
|
168
|
-
os.write(fd, data)
|
|
169
|
-
finally:
|
|
170
|
-
os.close(fd)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def append_observed(
|
|
174
|
-
*,
|
|
175
|
-
out_path,
|
|
176
|
-
task_key,
|
|
177
|
-
phase,
|
|
178
|
-
agent,
|
|
179
|
-
agent_role,
|
|
180
|
-
model,
|
|
181
|
-
error_type,
|
|
182
|
-
command,
|
|
183
|
-
command_kind,
|
|
184
|
-
exit_code,
|
|
185
|
-
duration_ms,
|
|
186
|
-
message,
|
|
187
|
-
stderr_excerpt,
|
|
188
|
-
context,
|
|
189
|
-
now=None,
|
|
190
|
-
):
|
|
191
|
-
"""Append a lead-observed error event to errors.jsonl."""
|
|
192
|
-
if error_type not in ALLOWED_ERROR_TYPES:
|
|
193
|
-
raise ValueError(f"invalid errorType: {error_type!r}")
|
|
194
|
-
if agent not in ALLOWED_AGENTS:
|
|
195
|
-
raise ValueError(f"invalid agent: {agent!r}")
|
|
196
|
-
if agent_role not in ALLOWED_AGENT_ROLES:
|
|
197
|
-
raise ValueError(f"invalid agentRole: {agent_role!r}")
|
|
198
|
-
# Runs before append_jsonl_line so a rejected claim leaves no trace in the
|
|
199
|
-
# log: a written-then-flagged record is still a record someone can cite.
|
|
200
|
-
context = normalize_cause_context(context, message=message)
|
|
201
|
-
ts = _iso(now or _now_utc())
|
|
202
|
-
rec = {
|
|
203
|
-
"ts": ts,
|
|
204
|
-
"recordedAt": ts,
|
|
205
|
-
"taskKey": task_key,
|
|
206
|
-
"phase": str(phase),
|
|
207
|
-
"agent": agent,
|
|
208
|
-
"agentRole": agent_role,
|
|
209
|
-
"model": model,
|
|
210
|
-
"source": "lead-observed",
|
|
211
|
-
"errorType": error_type,
|
|
212
|
-
"command": command,
|
|
213
|
-
"commandKind": command_kind,
|
|
214
|
-
"exitCode": exit_code,
|
|
215
|
-
"durationMs": duration_ms,
|
|
216
|
-
"message": message,
|
|
217
|
-
"stderrExcerpt": truncate_stderr(stderr_excerpt),
|
|
218
|
-
"context": context,
|
|
219
|
-
}
|
|
220
|
-
append_jsonl_line(out_path, rec)
|
|
221
|
-
return rec
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def dump_from_worker_sidecar(
|
|
225
|
-
*,
|
|
226
|
-
sidecar_path,
|
|
227
|
-
out_path,
|
|
228
|
-
task_key,
|
|
229
|
-
agent,
|
|
230
|
-
agent_role,
|
|
231
|
-
model,
|
|
232
|
-
now=None,
|
|
233
|
-
):
|
|
234
|
-
"""Read worker sidecar errors[] and append each to errors.jsonl with
|
|
235
|
-
Lead-side metadata filled in. Returns number of records appended.
|
|
236
|
-
|
|
237
|
-
Raises ValueError if:
|
|
238
|
-
- ``agent`` or ``agent_role`` is not in the allow-lists
|
|
239
|
-
- sidecar ``schemaVersion`` is not in ``SUPPORTED_SIDECAR_SCHEMA_VERSIONS``
|
|
240
|
-
- any entry's ``errorType`` is not in ``ALLOWED_ERROR_TYPES``
|
|
241
|
-
- any entry asserts a blocking cause without its required evidence
|
|
242
|
-
|
|
243
|
-
Returns 0 (no-op) if the sidecar file does not exist or its
|
|
244
|
-
``errors`` list is empty.
|
|
245
|
-
|
|
246
|
-
Partial-failure semantics: entries are validated and appended in
|
|
247
|
-
order. If entry N fails validation, entries 0..N-1 have already
|
|
248
|
-
been written to ``out_path`` and are NOT rolled back. Callers that
|
|
249
|
-
require atomicity must validate the sidecar payload before invoking
|
|
250
|
-
this function.
|
|
251
|
-
"""
|
|
252
|
-
if agent not in ALLOWED_AGENTS:
|
|
253
|
-
raise ValueError(f"invalid agent: {agent!r}")
|
|
254
|
-
if agent_role not in ALLOWED_AGENT_ROLES:
|
|
255
|
-
raise ValueError(f"invalid agentRole: {agent_role!r}")
|
|
256
|
-
p = Path(sidecar_path)
|
|
257
|
-
if not p.exists():
|
|
258
|
-
return 0
|
|
259
|
-
payload = json.loads(p.read_text())
|
|
260
|
-
schema = payload.get("schemaVersion")
|
|
261
|
-
if schema not in SUPPORTED_SIDECAR_SCHEMA_VERSIONS:
|
|
262
|
-
raise ValueError(f"unsupported sidecar schemaVersion: {schema!r}")
|
|
263
|
-
entries = payload.get("errors") or []
|
|
264
|
-
recorded_at = _iso(now or _now_utc())
|
|
265
|
-
count = 0
|
|
266
|
-
for e in entries:
|
|
267
|
-
et = e.get("errorType")
|
|
268
|
-
if et not in ALLOWED_ERROR_TYPES:
|
|
269
|
-
raise ValueError(f"invalid errorType in sidecar: {et!r}")
|
|
270
|
-
entry_context = normalize_cause_context(
|
|
271
|
-
e.get("context"), message=e.get("message")
|
|
272
|
-
)
|
|
273
|
-
rec = {
|
|
274
|
-
"ts": e.get("ts"),
|
|
275
|
-
"recordedAt": recorded_at,
|
|
276
|
-
"taskKey": task_key,
|
|
277
|
-
"phase": str(e.get("phase")) if e.get("phase") is not None else None,
|
|
278
|
-
"agent": agent,
|
|
279
|
-
"agentRole": agent_role,
|
|
280
|
-
"model": model,
|
|
281
|
-
"source": "worker-reported",
|
|
282
|
-
"errorType": et,
|
|
283
|
-
"command": e.get("command"),
|
|
284
|
-
"commandKind": e.get("commandKind"),
|
|
285
|
-
"exitCode": e.get("exitCode"),
|
|
286
|
-
"durationMs": e.get("durationMs"),
|
|
287
|
-
"message": e.get("message"),
|
|
288
|
-
"stderrExcerpt": truncate_stderr(e.get("stderrExcerpt")),
|
|
289
|
-
"context": entry_context,
|
|
290
|
-
}
|
|
291
|
-
append_jsonl_line(out_path, rec)
|
|
292
|
-
count += 1
|
|
293
|
-
return count
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ALLOWED_AGENT_ROLES",
|
|
34
|
+
"ALLOWED_AGENTS",
|
|
35
|
+
"ALLOWED_CAUSES",
|
|
36
|
+
"ALLOWED_ERROR_TYPES",
|
|
37
|
+
"CAUSE_EVIDENCE_FIELDS",
|
|
38
|
+
"CAUSE_PROBE_MAX_BYTES",
|
|
39
|
+
"PIPE_BUF_BYTES",
|
|
40
|
+
"STDERR_EXCERPT_MAX_BYTES",
|
|
41
|
+
"SUPPORTED_SIDECAR_SCHEMA_VERSIONS",
|
|
42
|
+
"TRUNCATION_SUFFIX",
|
|
43
|
+
"append_jsonl_line",
|
|
44
|
+
"append_observed",
|
|
45
|
+
"dump_from_worker_sidecar",
|
|
46
|
+
"main",
|
|
47
|
+
"normalize_cause_context",
|
|
48
|
+
"truncate_stderr",
|
|
49
|
+
]
|
|
294
50
|
|
|
295
51
|
|
|
296
52
|
def _build_parser():
|
|
@@ -235,6 +235,15 @@ prompt/result/audit paths. The returned `promptPath` is the only body that may
|
|
|
235
235
|
be dispatched; do not append role prose or reconstruct model headers after
|
|
236
236
|
materialization.
|
|
237
237
|
|
|
238
|
+
If the dispatch gate then rejects that prompt, fix the task-instructions file
|
|
239
|
+
and re-run the same `materialize` call with `--replace-undispatched` — keep the
|
|
240
|
+
`--invocation-id`. A published prompt is otherwise immutable, so without that
|
|
241
|
+
flag the retry fails as `existing_invocation_conflict`; do NOT delete the
|
|
242
|
+
reservation under `prompts/.agent-invocations` and do NOT mint a second
|
|
243
|
+
invocation id to get around it, because both detach the audit chain from the
|
|
244
|
+
call it describes. The flag is checked: once any dispatch row names this
|
|
245
|
+
invocation, the prompt is history and the replacement is refused.
|
|
246
|
+
|
|
238
247
|
Run `okstra agent-prompt verify --run-manifest <path> --metadata
|
|
239
248
|
<metadataPath> --json` immediately before dispatch. A failed verification is a
|
|
240
249
|
pre-dispatch contract failure. For `runner=native-session`, pass only the
|
|
@@ -261,7 +270,7 @@ Call `await_workers(handles)` through the same adapter and apply the shared term
|
|
|
261
270
|
|
|
262
271
|
### Required reverify-prompt anchor headers (BLOCKING)
|
|
263
272
|
|
|
264
|
-
Every reverify prompt MUST start with these
|
|
273
|
+
Every reverify prompt MUST start with these 8 anchor headers — in this exact order, before any other content:
|
|
265
274
|
|
|
266
275
|
```
|
|
267
276
|
**Project Root:** <absolute-path>
|
|
@@ -269,7 +278,6 @@ Every reverify prompt MUST start with these 9 anchor headers — in this exact o
|
|
|
269
278
|
**Result Path:** runs/<task-type>/worker-results/<role-slug>-reverify-r<N>-<task-type>-<seq>.md
|
|
270
279
|
**Audit sidecar path:** <absolute-path>
|
|
271
280
|
Assigned worker prompt history path: <Project Root>/<Prompt History Path>
|
|
272
|
-
**Model:** <role>, <modelExecutionValue>
|
|
273
281
|
**Errors log path:** <absolute-path>
|
|
274
282
|
**Errors sidecar path:** <absolute-path>
|
|
275
283
|
**Read scope:** Read only the paths this prompt enumerates (`[Required reading]`, `## Inputs`, verification-target paths) plus source/evidence paths a finding must cite. Host session instructions (SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, `SKILL.md`, or other artifacts outside `<PROJECT_ROOT>/.okstra/`. If an un-enumerated file seems essential, record it under *Missing Information or Assumptions* instead of reading it.
|
|
@@ -281,7 +289,9 @@ Before dispatch, materialize `**Audit sidecar path:**` by passing the exact reve
|
|
|
281
289
|
|
|
282
290
|
The two errors paths carry the same absolute values the lead forwarded in the initial Phase 4 dispatch for that role (source: the launch prompt's `## Run Logs (error-log wiring)` section). Omitting either one makes `worker-dispatch` reject the CLI invocation before it starts the provider process — the path-delivery contract in [team-contract](./team-contract.md) "Error reporting" is not relaxed for reverify.
|
|
283
291
|
|
|
284
|
-
Relative to the Phase 4 anchor set rendered by `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`, a reverify prompt
|
|
292
|
+
Relative to the Phase 4 anchor set rendered by `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`, a reverify prompt drops two anchors whose targets lightweight mode never reads: `**Worker Preamble Path:**` and `**Coding preflight pack:**`.
|
|
293
|
+
|
|
294
|
+
**Where the composer's sections go.** `okstra agent-prompt materialize` (§"Invocation materialization gate") writes the dispatched body itself, as: these anchors, then the model-assignment block it appends (`**Provider:**`, `**Model:**`, `**Model execution value:**`, `**Runner:**`, `**Host runtime:**`, and `**Host model value:**` for a native host), then `## Duty Contract`, then `## Task Instructions` followed verbatim by the task-instructions file the lead wrote. So the lead authors only the last part, and every rule below about ordering — the phase boundary before the instruction headings, the `**Model:** <role>, <modelExecutionValue>` line — is about the lead's own file, not about the composed document. The composer's `**Model:** <modelExecutionValue>` anchor is a different line with a different shape; do not try to reshape it, and do not count it among the 8.
|
|
285
295
|
|
|
286
296
|
For an `antigravity` assignment, append the exact `PLAIN_FILE_WRITE_HEADER`
|
|
287
297
|
value from `okstra_ctl.worker_prompt_headers` immediately after
|
|
@@ -290,20 +300,28 @@ persisted initial Phase 4 prompt; do not paraphrase or reconstruct it. If the
|
|
|
290
300
|
persisted initial prompt does not contain that generated header, abort the
|
|
291
301
|
reverify dispatch and record a `contract-violation` event instead of
|
|
292
302
|
dispatching without the plain-file safeguard. This provider-specific header is
|
|
293
|
-
outside the common
|
|
303
|
+
outside the common 8-header count above. Other providers do not receive it.
|
|
294
304
|
|
|
295
305
|
The rationale for both drops is §"Reverify prompt: required-reading suppression" below.
|
|
296
306
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
instructions:
|
|
307
|
+
The task-instructions file the lead writes MUST open with this block, before any
|
|
308
|
+
`##` heading of its own:
|
|
300
309
|
|
|
301
310
|
```markdown
|
|
311
|
+
**Model:** <role>, <modelExecutionValue>
|
|
302
312
|
**Task Type:** <task-manifest taskType>
|
|
303
313
|
**Forbidden actions:**
|
|
304
314
|
<active-run-context workflow.forbiddenActions, verbatim>
|
|
305
315
|
```
|
|
306
316
|
|
|
317
|
+
This is the same placement `okstra_ctl.worker_prompt_body` uses for an initial
|
|
318
|
+
Phase 4 prompt, and it is where the checks look: `validate_reverify_prompt()`
|
|
319
|
+
reads the region after `## Task Instructions`, so a `**Model:**` line left in
|
|
320
|
+
the anchors is invisible to it, and a phase boundary written below the file's
|
|
321
|
+
first heading fails. Both rules are about this file — the composer's own
|
|
322
|
+
`## Duty Contract` heading sits above everything here and is not what they
|
|
323
|
+
measure against.
|
|
324
|
+
|
|
307
325
|
Do not summarize, shorten, or reconstruct the forbidden-actions text. The
|
|
308
326
|
selected adapter validates the task type and exact block through
|
|
309
327
|
`okstra_ctl.worker_prompt_contract.validate_reverify_prompt()` before starting
|
|
@@ -587,6 +605,34 @@ persisted assignment or either model value required by its runner is absent,
|
|
|
587
605
|
record `critic-skipped: model-unresolved`; never resolve a replacement model.
|
|
588
606
|
Result path: `runs/<task-type>/worker-results/<provider>-worker-critic-<task-type>-<seq>.md`.
|
|
589
607
|
|
|
608
|
+
**What the critic task-instructions file MUST contain (BLOCKING).** A critic
|
|
609
|
+
dispatch is not a reverify dispatch: `dispatchKind = "critic"` keeps
|
|
610
|
+
`audience = "analysis"`, so `worker_prompt_contract.validate_initial_prompts`
|
|
611
|
+
judges it by the full initial-analysis contract. Two of those requirements are
|
|
612
|
+
satisfied by the generated body for a Phase 4 worker
|
|
613
|
+
(`okstra_ctl.worker_prompt_body`) and by nothing at all for a critic, whose
|
|
614
|
+
instructions the lead writes — the materializer's anchor block supplies neither.
|
|
615
|
+
Put both in the instructions file:
|
|
616
|
+
|
|
617
|
+
```markdown
|
|
618
|
+
**Prompt Delivery Mode:** eager-include
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
and, under the file's `## Inputs`, exactly one line in this shape — the literal
|
|
622
|
+
label and the backticks are what the check matches, so a bare path or a
|
|
623
|
+
differently-worded label counts as zero:
|
|
624
|
+
|
|
625
|
+
```markdown
|
|
626
|
+
- Primary analysis packet: `<path ending in analysis-packet.md>`
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Omitting either one fails `okstra team dispatch --dispatch-kind critic` before
|
|
630
|
+
any process starts, reported as `<task-type> prompt contract: <worker>: exactly
|
|
631
|
+
one Primary analysis packet path is required (found 0)` and `exactly one
|
|
632
|
+
non-empty **Prompt Delivery Mode:** header is required`. Fix the instructions
|
|
633
|
+
file and re-materialize with `--replace-undispatched` (§"Invocation
|
|
634
|
+
materialization gate") rather than editing the published prompt.
|
|
635
|
+
|
|
590
636
|
The `-worker-` token is load-bearing, not decoration: the critic prompt carries the same generated anchor headers as every other worker ([team-contract](./team-contract.md) §"Worker prompts"), and its `**Audit sidecar path:**` comes from passing that result path through `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()`, which inserts `-audit-` after the token and raises without it. A `<provider>-critic-...` name leaves the lead choosing between breaking the contract and hand-inventing the sidecar name. Note that `originWorker` stays `"<provider>-critic"` — that is a worker id in the convergence state, not a filename, and the two do not have to match.
|
|
591
637
|
|
|
592
638
|
The critic prompt carries the full analysis contract those anchor headers belong to — worker preamble, error-contract path, audit sidecar, packet boundary — but it is **exempt from the initial-analysis equality group**. Initial analysis workers must receive byte-identical normalized bodies (`worker_prompt_contract.validate_analysis_prompt_set`), and a critic body is deliberately unlike them, so `okstra_ctl.worker_prompt_policy.resolve_prompt_plan` resolves `dispatchKind = "critic"` to `equality_group = None` while keeping `audience = "analysis"`. Never reshape a critic prompt to match the initial one to satisfy that check — matching it would delete the pass. **Enforced:** `tests/contract/test_critic_prompt_equality_exemption.py` pins both directions — the critic is exempt, and two mismatched *initial* prompts still fail.
|
|
@@ -270,7 +270,7 @@ okstra error-log append-from-worker \
|
|
|
270
270
|
|
|
271
271
|
`--agent`, `--agent-role`, and `--error-type` are **closed enums**, not free-form labels — the role names used elsewhere in these contracts (`Codex worker`, `Claude worker`) are rejected. Use exactly:
|
|
272
272
|
|
|
273
|
-
- `--agent` — `claude-worker` | `codex-worker` | `antigravity-worker` | `report-writer`
|
|
273
|
+
- `--agent` — `claude-worker` | `codex-worker` | `antigravity-worker` | `grok-worker` | `kimi-worker` | `report-writer`
|
|
274
274
|
- `--agent-role` — `lead` | `worker` | `report-writer`
|
|
275
275
|
- `--error-type` — `cli-failure` | `contract-violation` | `tool-failure`
|
|
276
276
|
|
|
@@ -125,6 +125,9 @@ class AgentInvocationRequest:
|
|
|
125
125
|
prompt_path: Path
|
|
126
126
|
metadata_path: Path
|
|
127
127
|
dispatch_kind: str
|
|
128
|
+
# Republish a prompt this invocation id already wrote, but only while no
|
|
129
|
+
# dispatch has used it. See `_publish_or_reuse`.
|
|
130
|
+
replace_undispatched: bool = False
|
|
128
131
|
|
|
129
132
|
|
|
130
133
|
@dataclass(frozen=True)
|
|
@@ -254,13 +257,13 @@ def prepare_agent_invocation(
|
|
|
254
257
|
materialized = _materialize(request)
|
|
255
258
|
if materialized.reservation_root is None:
|
|
256
259
|
with _exclusive_lock(_prompt_lock_path(request.prompt_path)):
|
|
257
|
-
_publish_or_reuse(materialized)
|
|
260
|
+
_publish_or_reuse(request, materialized)
|
|
258
261
|
else:
|
|
259
262
|
reservation_lock = materialized.reservation_root / "publish.lock"
|
|
260
263
|
with _exclusive_lock(reservation_lock):
|
|
261
264
|
_publish_or_reuse_reservation(materialized)
|
|
262
265
|
with _exclusive_lock(_prompt_lock_path(request.prompt_path)):
|
|
263
|
-
_publish_or_reuse(materialized)
|
|
266
|
+
_publish_or_reuse(request, materialized)
|
|
264
267
|
errors = verify_agent_invocation(
|
|
265
268
|
request.metadata_path,
|
|
266
269
|
project_root=request.project_root,
|
|
@@ -889,7 +892,9 @@ def _publish_or_reuse_reservation(materialized: _MaterializedInvocation) -> None
|
|
|
889
892
|
_publish_exclusive(path, _pretty_json(reservation))
|
|
890
893
|
|
|
891
894
|
|
|
892
|
-
def _publish_or_reuse(
|
|
895
|
+
def _publish_or_reuse(
|
|
896
|
+
request: AgentInvocationRequest, materialized: _MaterializedInvocation
|
|
897
|
+
) -> None:
|
|
893
898
|
prompt = materialized.prepared.prompt_path
|
|
894
899
|
metadata = materialized.prepared.metadata_path
|
|
895
900
|
prompt_exists = prompt.exists()
|
|
@@ -897,7 +902,8 @@ def _publish_or_reuse(materialized: _MaterializedInvocation) -> None:
|
|
|
897
902
|
if metadata_exists and not prompt_exists:
|
|
898
903
|
raise _existing_conflict("metadata exists without prompt")
|
|
899
904
|
if prompt_exists and prompt.read_bytes() != materialized.prompt_bytes:
|
|
900
|
-
|
|
905
|
+
_replace_undispatched(request, materialized)
|
|
906
|
+
return
|
|
901
907
|
if prompt_exists and metadata_exists:
|
|
902
908
|
if metadata.read_bytes() != materialized.metadata_bytes:
|
|
903
909
|
raise _existing_conflict("existing metadata differs")
|
|
@@ -907,6 +913,73 @@ def _publish_or_reuse(materialized: _MaterializedInvocation) -> None:
|
|
|
907
913
|
_publish_exclusive(metadata, materialized.metadata_bytes)
|
|
908
914
|
|
|
909
915
|
|
|
916
|
+
def _replace_undispatched(
|
|
917
|
+
request: AgentInvocationRequest, materialized: _MaterializedInvocation
|
|
918
|
+
) -> None:
|
|
919
|
+
"""Rewrite a prompt this invocation id wrote but no dispatch ever used.
|
|
920
|
+
|
|
921
|
+
Immutability protects the audit chain: a prompt that a worker ran must keep
|
|
922
|
+
reading the way it ran. A prompt that failed a pre-dispatch gate ran
|
|
923
|
+
nowhere, and there the same rule cost a lead its only clean way forward —
|
|
924
|
+
fix the instruction file and the republish is refused, so the remaining
|
|
925
|
+
moves were to delete the reservation by hand or to burn a second invocation
|
|
926
|
+
id and blur which call is which. Both damage the chain this protects.
|
|
927
|
+
|
|
928
|
+
The exit is explicit (`--replace-undispatched`) and it is checked, not
|
|
929
|
+
trusted: a single recorded dispatch against this invocation id — worker or
|
|
930
|
+
agent — puts the prompt back under the original rule.
|
|
931
|
+
"""
|
|
932
|
+
if not request.replace_undispatched:
|
|
933
|
+
raise _existing_conflict("existing prompt differs")
|
|
934
|
+
dispatched = _recorded_dispatch_ids(request)
|
|
935
|
+
if dispatched:
|
|
936
|
+
raise _existing_conflict(
|
|
937
|
+
"existing prompt differs and this invocation was already "
|
|
938
|
+
f"dispatched as {', '.join(sorted(dispatched))}"
|
|
939
|
+
)
|
|
940
|
+
_publish_exclusive(
|
|
941
|
+
materialized.prepared.prompt_path, materialized.prompt_bytes, replace=True
|
|
942
|
+
)
|
|
943
|
+
_publish_exclusive(
|
|
944
|
+
materialized.prepared.metadata_path,
|
|
945
|
+
materialized.metadata_bytes,
|
|
946
|
+
replace=True,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _recorded_dispatch_ids(request: AgentInvocationRequest) -> set[str]:
|
|
951
|
+
"""Dispatch rows in team-state that already reference this invocation id.
|
|
952
|
+
|
|
953
|
+
Both ledgers are read: `agentDispatches` is written by the host-native
|
|
954
|
+
spec-link gate, `workerDispatches` by the deterministic worker dispatch, and
|
|
955
|
+
a prompt used by either one is history.
|
|
956
|
+
"""
|
|
957
|
+
if request.run_manifest_path is None:
|
|
958
|
+
return set()
|
|
959
|
+
manifest = _load_json_object(request.run_manifest_path, "run manifest")
|
|
960
|
+
team_state_value = manifest.get("teamStatePath")
|
|
961
|
+
if not isinstance(team_state_value, str) or not team_state_value.strip():
|
|
962
|
+
return set()
|
|
963
|
+
team_state_path = _project_path(
|
|
964
|
+
request.project_root, team_state_value, must_exist=False
|
|
965
|
+
)
|
|
966
|
+
team_state = _read_json_if_object(team_state_path)
|
|
967
|
+
if team_state is None:
|
|
968
|
+
return set()
|
|
969
|
+
found: set[str] = set()
|
|
970
|
+
for key in ("agentDispatches", "workerDispatches"):
|
|
971
|
+
rows = team_state.get(key)
|
|
972
|
+
if not isinstance(rows, list):
|
|
973
|
+
continue
|
|
974
|
+
for row in rows:
|
|
975
|
+
if not isinstance(row, Mapping):
|
|
976
|
+
continue
|
|
977
|
+
if row.get("invocationId") != request.invocation_id:
|
|
978
|
+
continue
|
|
979
|
+
found.add(str(row.get("dispatchId") or request.invocation_id))
|
|
980
|
+
return found
|
|
981
|
+
|
|
982
|
+
|
|
910
983
|
def _existing_conflict(detail: str) -> AgentInvocationError:
|
|
911
984
|
return AgentInvocationError(
|
|
912
985
|
f"existing_invocation_conflict: {detail}",
|
|
@@ -930,7 +1003,7 @@ def _prompt_lock_path(prompt_path: Path) -> Path:
|
|
|
930
1003
|
return prompt_path.with_name(prompt_path.name + ".publish.lock")
|
|
931
1004
|
|
|
932
1005
|
|
|
933
|
-
def _publish_exclusive(path: Path, body: bytes) -> None:
|
|
1006
|
+
def _publish_exclusive(path: Path, body: bytes, *, replace: bool = False) -> None:
|
|
934
1007
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
935
1008
|
temp_path: Path | None = None
|
|
936
1009
|
try:
|
|
@@ -944,7 +1017,14 @@ def _publish_exclusive(path: Path, body: bytes) -> None:
|
|
|
944
1017
|
handle.write(body)
|
|
945
1018
|
handle.flush()
|
|
946
1019
|
os.fsync(handle.fileno())
|
|
947
|
-
|
|
1020
|
+
# `link` is what makes a first publish exclusive — it fails rather than
|
|
1021
|
+
# overwrite. A sanctioned replacement wants the opposite and still needs
|
|
1022
|
+
# to be atomic, so it swaps the same fully-written temp file into place.
|
|
1023
|
+
if replace:
|
|
1024
|
+
os.replace(temp_path, path)
|
|
1025
|
+
temp_path = None
|
|
1026
|
+
else:
|
|
1027
|
+
os.link(temp_path, path)
|
|
948
1028
|
except FileExistsError as exc:
|
|
949
1029
|
raise _existing_conflict(f"published path already exists: {path}") from exc
|
|
950
1030
|
finally:
|
|
@@ -71,6 +71,13 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
71
71
|
materialize.add_argument("--model-role")
|
|
72
72
|
materialize.add_argument("--model", default="")
|
|
73
73
|
materialize.add_argument("--purpose")
|
|
74
|
+
materialize.add_argument(
|
|
75
|
+
"--replace-undispatched",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="rewrite a prompt this invocation id already wrote, allowed only "
|
|
78
|
+
"while no dispatch has referenced it — the exit for a prompt that "
|
|
79
|
+
"failed a pre-dispatch gate and never ran",
|
|
80
|
+
)
|
|
74
81
|
materialize.add_argument("--json", action="store_true")
|
|
75
82
|
|
|
76
83
|
verify = commands.add_parser("verify")
|
|
@@ -329,6 +336,7 @@ def _materialize_run(
|
|
|
329
336
|
prompt_path=prompt_path,
|
|
330
337
|
metadata_path=prompt_path.with_name(prompt_path.name + ".meta.json"),
|
|
331
338
|
dispatch_kind=args.dispatch_kind,
|
|
339
|
+
replace_undispatched=args.replace_undispatched,
|
|
332
340
|
))
|
|
333
341
|
|
|
334
342
|
|