okstra 0.167.0 → 0.169.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/docs/architecture/storage-model.md +57 -1
- package/docs/architecture.md +70 -2
- package/docs/cli.md +8 -4
- package/docs/for-ai/skills/okstra-code-review.md +3 -2
- package/docs/for-ai/skills/okstra-schedule-gen.md +3 -1
- package/docs/pr-template-usage.md +10 -6
- package/docs/project-structure-overview.md +14 -11
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +6 -5
- package/runtime/agents/workers/report-writer-worker.md +9 -4
- package/runtime/agents/workers/translator-worker.md +6 -4
- package/runtime/prompts/coding-preflight/architectures/hexagonal.md +27 -1
- package/runtime/prompts/coding-preflight/clean-code.md +13 -0
- package/runtime/prompts/duties/acceptance-critic.md +24 -0
- package/runtime/prompts/duties/acceptance-verifier.md +24 -0
- package/runtime/prompts/duties/analysis-worker.md +24 -0
- package/runtime/prompts/duties/code-reviewer.md +24 -0
- package/runtime/prompts/duties/common.md +35 -0
- package/runtime/prompts/duties/implementation-executor.md +24 -0
- package/runtime/prompts/duties/implementation-verifier.md +24 -0
- package/runtime/prompts/duties/lead.md +24 -0
- package/runtime/prompts/duties/report-writer.md +24 -0
- package/runtime/prompts/duties/reverification-worker.md +24 -0
- package/runtime/prompts/duties/schedule-verifier.md +24 -0
- package/runtime/prompts/duties/scope-critic.md +24 -0
- package/runtime/prompts/duties/translator.md +24 -0
- package/runtime/prompts/lead/convergence.md +51 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +10 -20
- package/runtime/prompts/lead/plan-body-verification.md +16 -1
- package/runtime/prompts/lead/report-writer.md +20 -5
- package/runtime/prompts/lead/team-contract.md +13 -13
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
- package/runtime/prompts/profiles/_implementation-executor.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
- package/runtime/prompts/profiles/implementation.md +4 -2
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +6 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +8 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +33 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +13 -12
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +6 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +2 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +6 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +6 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +1502 -0
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +788 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -107
- package/runtime/python/okstra_ctl/context_cost.py +46 -5
- package/runtime/python/okstra_ctl/dispatch_core.py +312 -37
- package/runtime/python/okstra_ctl/dispatch_state.py +461 -36
- package/runtime/python/okstra_ctl/doctor.py +150 -16
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +87 -9
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +214 -23
- package/runtime/python/okstra_ctl/path_hints.py +26 -0
- package/runtime/python/okstra_ctl/paths.py +20 -0
- package/runtime/python/okstra_ctl/ports/__init__.py +8 -0
- package/runtime/python/okstra_ctl/ports/host.py +3 -0
- package/runtime/python/okstra_ctl/ports/host_model.py +60 -0
- package/runtime/python/okstra_ctl/pr_template.py +3 -6
- package/runtime/python/okstra_ctl/registry/host_registry.py +5 -0
- package/runtime/python/okstra_ctl/render.py +217 -12
- package/runtime/python/okstra_ctl/report_finalize.py +44 -0
- package/runtime/python/okstra_ctl/run.py +368 -51
- package/runtime/python/okstra_ctl/session.py +16 -12
- package/runtime/python/okstra_ctl/team.py +11 -11
- package/runtime/python/okstra_ctl/worker_dispatch.py +104 -0
- package/runtime/python/okstra_ctl/worker_prompt_body.py +5 -38
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +38 -1
- package/runtime/skills/okstra-code-review/SKILL.md +22 -3
- package/runtime/skills/okstra-run/SKILL.md +16 -1
- package/runtime/skills/okstra-schedule-gen/SKILL.md +15 -1
- package/runtime/templates/implementation-worker-preamble.md +0 -10
- package/runtime/templates/report-writer-prompt-preamble.md +0 -9
- package/runtime/templates/reports/settings.template.json +0 -11
- package/runtime/templates/worker-prompt-preamble.md +0 -10
- package/runtime/validators/lib/fixtures.sh +93 -0
- package/runtime/validators/lib/validate-assets.sh +0 -8
- package/runtime/validators/validate-run.py +182 -0
- package/src/cli-registry.mjs +14 -0
- package/src/commands/execute/agent-prompt.mjs +25 -0
- package/src/commands/execute/codex-dispatch.mjs +6 -63
- package/src/commands/execute/worker-dispatch.mjs +76 -0
- package/src/commands/lifecycle/doctor.mjs +18 -3
- package/src/commands/lifecycle/install.mjs +33 -15
- package/src/commands/lifecycle/uninstall.mjs +4 -3
- package/src/lib/install-assets.mjs +9 -0
- package/runtime/agents/workers/antigravity-worker.md +0 -259
- package/runtime/agents/workers/codex-worker.md +0 -259
- package/runtime/agents/workers/grok-worker.md +0 -259
- package/runtime/agents/workers/kimi-worker.md +0 -259
- package/runtime/prompts/coding-preflight/scripts/preedit-check.sh +0 -79
- package/runtime/templates/operating-standard.md +0 -22
- package/src/lib/worker-agent-render.mjs +0 -50
- /package/runtime/templates/{prd → pr}/pr-body.template.md +0 -0
|
@@ -17,14 +17,22 @@ Enforced by `tests/contract/test_dispatcher_shared_helpers.py`.
|
|
|
17
17
|
"""
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import contextlib
|
|
21
|
+
import fcntl
|
|
20
22
|
import json
|
|
21
23
|
import os
|
|
24
|
+
import tempfile
|
|
22
25
|
from dataclasses import dataclass
|
|
23
26
|
from datetime import datetime, timezone
|
|
24
27
|
from pathlib import Path
|
|
25
28
|
from typing import Any, Callable, Mapping, Sequence
|
|
26
29
|
|
|
27
30
|
from . import cmux
|
|
31
|
+
from .agent_invocation import (
|
|
32
|
+
AgentInvocationError,
|
|
33
|
+
agent_model_assignment_from_payload,
|
|
34
|
+
verify_agent_invocation,
|
|
35
|
+
)
|
|
28
36
|
from .worker_prompt_contract import (
|
|
29
37
|
PromptRecord,
|
|
30
38
|
validate_initial_prompt_records,
|
|
@@ -95,6 +103,17 @@ class WorkerJob:
|
|
|
95
103
|
role: str
|
|
96
104
|
idle_timeout_seconds: int
|
|
97
105
|
dispatch_kind: str
|
|
106
|
+
invocation_id: str = ""
|
|
107
|
+
audience: str = ""
|
|
108
|
+
assignment_ref: str = ""
|
|
109
|
+
prompt_metadata_path: Path = Path()
|
|
110
|
+
catalog_digest: str = ""
|
|
111
|
+
assignment_digest: str = ""
|
|
112
|
+
duty_digest: str = ""
|
|
113
|
+
instruction_digest: str = ""
|
|
114
|
+
prompt_digest: str = ""
|
|
115
|
+
host_model_value: str | None = None
|
|
116
|
+
enforcement_mode: str = ""
|
|
98
117
|
|
|
99
118
|
@property
|
|
100
119
|
def command(self) -> list[str]:
|
|
@@ -118,7 +137,7 @@ class WorkerJob:
|
|
|
118
137
|
return QUIET if self.backend == BACKEND_CLI_WRAPPER else LIVE
|
|
119
138
|
|
|
120
139
|
def to_payload(self) -> dict[str, Any]:
|
|
121
|
-
|
|
140
|
+
payload = {
|
|
122
141
|
"workerId": self.worker_id,
|
|
123
142
|
"provider": self.provider,
|
|
124
143
|
"backend": self.backend,
|
|
@@ -133,6 +152,27 @@ class WorkerJob:
|
|
|
133
152
|
"dispatchKind": self.dispatch_kind,
|
|
134
153
|
"command": self.command,
|
|
135
154
|
}
|
|
155
|
+
if self.invocation_id:
|
|
156
|
+
payload.update({
|
|
157
|
+
"invocationId": self.invocation_id,
|
|
158
|
+
"audience": self.audience,
|
|
159
|
+
"assignmentRef": self.assignment_ref,
|
|
160
|
+
"promptMetadataPath": str(self.prompt_metadata_path),
|
|
161
|
+
"digests": self.digests,
|
|
162
|
+
"hostModelValue": self.host_model_value,
|
|
163
|
+
"enforcementMode": self.enforcement_mode,
|
|
164
|
+
})
|
|
165
|
+
return payload
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def digests(self) -> dict[str, str]:
|
|
169
|
+
return {
|
|
170
|
+
"catalogDigest": self.catalog_digest,
|
|
171
|
+
"assignmentDigest": self.assignment_digest,
|
|
172
|
+
"dutyDigest": self.duty_digest,
|
|
173
|
+
"instructionDigest": self.instruction_digest,
|
|
174
|
+
"promptDigest": self.prompt_digest,
|
|
175
|
+
}
|
|
136
176
|
|
|
137
177
|
|
|
138
178
|
# --- run-manifest reads -------------------------------------------------------
|
|
@@ -201,11 +241,293 @@ def write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
|
201
241
|
# Write via temp file + os.replace so a crash mid-write cannot leave a
|
|
202
242
|
# truncated team-state.json that every later load_json_object rejects;
|
|
203
243
|
# os.replace is atomic on POSIX, matching run_context._atomic_write_json.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
temp_path: Path | None = None
|
|
246
|
+
try:
|
|
247
|
+
with tempfile.NamedTemporaryFile(
|
|
248
|
+
mode="w",
|
|
249
|
+
encoding="utf-8",
|
|
250
|
+
dir=path.parent,
|
|
251
|
+
prefix=f".{path.name}.",
|
|
252
|
+
suffix=".tmp",
|
|
253
|
+
delete=False,
|
|
254
|
+
) as handle:
|
|
255
|
+
temp_path = Path(handle.name)
|
|
256
|
+
handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
257
|
+
handle.flush()
|
|
258
|
+
os.fsync(handle.fileno())
|
|
259
|
+
os.replace(temp_path, path)
|
|
260
|
+
temp_path = None
|
|
261
|
+
finally:
|
|
262
|
+
if temp_path is not None:
|
|
263
|
+
temp_path.unlink(missing_ok=True)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@contextlib.contextmanager
|
|
267
|
+
def _team_state_lock(path: Path):
|
|
268
|
+
lock_path = path.with_name(path.name + ".lock")
|
|
269
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
handle = lock_path.open("a+")
|
|
271
|
+
try:
|
|
272
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
273
|
+
yield
|
|
274
|
+
finally:
|
|
275
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
276
|
+
handle.close()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def mutate_team_state(
|
|
280
|
+
team_state_path: Path,
|
|
281
|
+
mutation: Callable[[dict[str, Any]], bool],
|
|
282
|
+
) -> bool:
|
|
283
|
+
"""Apply one arbitrary team-state mutation under the shared file lock."""
|
|
284
|
+
with _team_state_lock(team_state_path):
|
|
285
|
+
payload = load_json_object(team_state_path, "team-state")
|
|
286
|
+
changed = mutation(payload)
|
|
287
|
+
if changed:
|
|
288
|
+
write_json(team_state_path, payload)
|
|
289
|
+
return changed
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def append_worker_dispatch(
|
|
293
|
+
team_state_path: Path,
|
|
294
|
+
record: Mapping[str, Any],
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Append one worker dispatch without losing another state writer's update."""
|
|
297
|
+
with _team_state_lock(team_state_path):
|
|
298
|
+
payload = load_json_object(team_state_path, "team-state")
|
|
299
|
+
dispatches = payload.setdefault("workerDispatches", [])
|
|
300
|
+
if not isinstance(dispatches, list):
|
|
301
|
+
raise DispatchError(
|
|
302
|
+
f"team-state workerDispatches must be an array: {team_state_path}"
|
|
303
|
+
)
|
|
304
|
+
dispatches.append(dict(record))
|
|
305
|
+
write_json(team_state_path, payload)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def update_worker_dispatch_status(
|
|
309
|
+
team_state_path: Path,
|
|
310
|
+
*,
|
|
311
|
+
prompt_path: Path,
|
|
312
|
+
attempt: int,
|
|
313
|
+
status: str,
|
|
314
|
+
reason: str,
|
|
315
|
+
) -> bool:
|
|
316
|
+
"""Settle the newest matching dispatch row under the shared state lock."""
|
|
317
|
+
with _team_state_lock(team_state_path):
|
|
318
|
+
payload = load_json_object(team_state_path, "team-state")
|
|
319
|
+
dispatches = payload.get("workerDispatches", [])
|
|
320
|
+
if not isinstance(dispatches, list):
|
|
321
|
+
raise DispatchError(
|
|
322
|
+
f"team-state workerDispatches must be an array: {team_state_path}"
|
|
323
|
+
)
|
|
324
|
+
matches = [
|
|
325
|
+
record
|
|
326
|
+
for record in dispatches
|
|
327
|
+
if dispatch_record_matches(record, prompt_path, attempt)
|
|
328
|
+
]
|
|
329
|
+
if not matches:
|
|
330
|
+
return False
|
|
331
|
+
# A re-send can reuse prompt + attempt. The newest row is the live one.
|
|
332
|
+
matches[-1]["status"] = status
|
|
333
|
+
matches[-1]["reason"] = reason
|
|
334
|
+
write_json(team_state_path, payload)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
_AGENT_DIGEST_KEYS = (
|
|
339
|
+
"catalogDigest",
|
|
340
|
+
"assignmentDigest",
|
|
341
|
+
"dutyDigest",
|
|
342
|
+
"instructionDigest",
|
|
343
|
+
"promptDigest",
|
|
344
|
+
)
|
|
345
|
+
_AGENT_ENFORCEMENT_MODES = frozenset({
|
|
346
|
+
"core-pre-dispatch",
|
|
347
|
+
"host-native-spec-link-gate",
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def record_verified_agent_dispatch(
|
|
352
|
+
*,
|
|
353
|
+
project_root: Path,
|
|
354
|
+
run_manifest_path: Path,
|
|
355
|
+
metadata_path: Path,
|
|
356
|
+
enforcement_mode: str,
|
|
357
|
+
allow_native_core: bool = False,
|
|
358
|
+
) -> dict[str, Any]:
|
|
359
|
+
"""Append one immutable dispatch-to-invocation association.
|
|
360
|
+
|
|
361
|
+
Host-native calls use this record as a specification link; it deliberately
|
|
362
|
+
does not claim that Okstra observed the bytes delivered by the host. A
|
|
363
|
+
code-owned lead process may set ``allow_native_core`` because lead model
|
|
364
|
+
assignments use the host's native model vocabulary even when Okstra owns
|
|
365
|
+
the outer provider CLI process.
|
|
366
|
+
"""
|
|
367
|
+
project_root = project_root.resolve()
|
|
368
|
+
run_manifest_path = resolve_project_path(
|
|
369
|
+
project_root, str(run_manifest_path)
|
|
370
|
+
).resolve(strict=True)
|
|
371
|
+
metadata_path = resolve_project_path(
|
|
372
|
+
project_root, str(metadata_path)
|
|
373
|
+
).resolve(strict=True)
|
|
374
|
+
if enforcement_mode not in _AGENT_ENFORCEMENT_MODES:
|
|
375
|
+
raise DispatchError(f"invalid agent enforcement mode: {enforcement_mode}")
|
|
376
|
+
manifest = load_json_object(run_manifest_path, "run manifest")
|
|
377
|
+
metadata = load_json_object(metadata_path, "agent invocation metadata")
|
|
378
|
+
assignment_ref = require_string(metadata, "assignmentRef")
|
|
379
|
+
assignments = manifest.get("invocationAssignments")
|
|
380
|
+
if not isinstance(assignments, Mapping):
|
|
381
|
+
raise DispatchError("run manifest has no invocationAssignments object")
|
|
382
|
+
try:
|
|
383
|
+
assignment = agent_model_assignment_from_payload(assignments.get(assignment_ref))
|
|
384
|
+
except AgentInvocationError as exc:
|
|
385
|
+
raise DispatchError(str(exc)) from exc
|
|
386
|
+
worker_id = require_string(metadata, "workerId")
|
|
387
|
+
audience = require_string(metadata, "audience")
|
|
388
|
+
invocation_id = require_string(metadata, "invocationId")
|
|
389
|
+
errors = verify_agent_invocation(
|
|
390
|
+
metadata_path,
|
|
391
|
+
project_root=project_root,
|
|
392
|
+
expected_run_manifest_path=run_manifest_path,
|
|
393
|
+
expected_assignment=assignment,
|
|
394
|
+
expected_invocation_id=invocation_id,
|
|
395
|
+
expected_worker_id=worker_id,
|
|
396
|
+
expected_assignment_ref=assignment_ref,
|
|
397
|
+
expected_audience=audience,
|
|
207
398
|
)
|
|
208
|
-
|
|
399
|
+
if errors:
|
|
400
|
+
raise DispatchError("agent invocation verification failed: " + "; ".join(errors))
|
|
401
|
+
if (
|
|
402
|
+
enforcement_mode == "host-native-spec-link-gate"
|
|
403
|
+
and assignment.runner != "native-session"
|
|
404
|
+
):
|
|
405
|
+
raise DispatchError(
|
|
406
|
+
"host-native enforcement requires a native-session assignment"
|
|
407
|
+
)
|
|
408
|
+
if (
|
|
409
|
+
enforcement_mode == "core-pre-dispatch"
|
|
410
|
+
and assignment.runner == "native-session"
|
|
411
|
+
and not allow_native_core
|
|
412
|
+
):
|
|
413
|
+
raise DispatchError(
|
|
414
|
+
"native-session assignment must use host-native-spec-link-gate"
|
|
415
|
+
)
|
|
416
|
+
digests = metadata.get("digests")
|
|
417
|
+
if not isinstance(digests, Mapping):
|
|
418
|
+
raise DispatchError("agent invocation digests are missing")
|
|
419
|
+
digest_values = {
|
|
420
|
+
key: require_string(digests, key) for key in _AGENT_DIGEST_KEYS
|
|
421
|
+
}
|
|
422
|
+
prompt = metadata.get("prompt")
|
|
423
|
+
if not isinstance(prompt, Mapping):
|
|
424
|
+
raise DispatchError("agent invocation prompt is missing")
|
|
425
|
+
prompt_path = require_string(prompt, "path")
|
|
426
|
+
dispatch_id = f"{invocation_id}:attempt-1"
|
|
427
|
+
record = {
|
|
428
|
+
"dispatchId": dispatch_id,
|
|
429
|
+
"workerId": worker_id,
|
|
430
|
+
"audience": audience,
|
|
431
|
+
"invocationId": invocation_id,
|
|
432
|
+
"assignmentRef": assignment_ref,
|
|
433
|
+
"promptPath": prompt_path,
|
|
434
|
+
"promptMetadataPath": _relative_project_path(project_root, metadata_path),
|
|
435
|
+
**digest_values,
|
|
436
|
+
"modelExecutionValue": assignment.model_execution_value,
|
|
437
|
+
"hostModelValue": assignment.host_model_value,
|
|
438
|
+
"enforcementMode": enforcement_mode,
|
|
439
|
+
"promptDeliveryVerified": False,
|
|
440
|
+
"status": "dispatched",
|
|
441
|
+
}
|
|
442
|
+
team_state_path = resolve_required_path(project_root, manifest, "teamStatePath")
|
|
443
|
+
with _team_state_lock(team_state_path):
|
|
444
|
+
team_state = load_json_object(team_state_path, "team-state")
|
|
445
|
+
dispatches = team_state.setdefault("agentDispatches", [])
|
|
446
|
+
if not isinstance(dispatches, list):
|
|
447
|
+
raise DispatchError("team-state agentDispatches must be an array")
|
|
448
|
+
existing = [
|
|
449
|
+
item for item in dispatches
|
|
450
|
+
if isinstance(item, Mapping) and item.get("dispatchId") == dispatch_id
|
|
451
|
+
]
|
|
452
|
+
if existing:
|
|
453
|
+
if len(existing) != 1 or dict(existing[0]) != record:
|
|
454
|
+
raise DispatchError(f"agent dispatch ID conflicts: {dispatch_id}")
|
|
455
|
+
return record
|
|
456
|
+
dispatches.append(record)
|
|
457
|
+
write_json(team_state_path, team_state)
|
|
458
|
+
return record
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def link_agent_dispatch_result(
|
|
462
|
+
*,
|
|
463
|
+
project_root: Path,
|
|
464
|
+
run_manifest_path: Path,
|
|
465
|
+
dispatch_id: str,
|
|
466
|
+
result_path: Path,
|
|
467
|
+
) -> dict[str, str]:
|
|
468
|
+
"""Link an existing result artifact to exactly one verified dispatch."""
|
|
469
|
+
project_root = project_root.resolve()
|
|
470
|
+
manifest_path = resolve_project_path(
|
|
471
|
+
project_root, str(run_manifest_path)
|
|
472
|
+
).resolve(strict=True)
|
|
473
|
+
manifest = load_json_object(manifest_path, "run manifest")
|
|
474
|
+
run_root = resolve_required_path(
|
|
475
|
+
project_root, manifest, "runDirectoryPath"
|
|
476
|
+
).resolve(strict=True)
|
|
477
|
+
resolved_result = resolve_project_path(
|
|
478
|
+
project_root, str(result_path)
|
|
479
|
+
).resolve(strict=True)
|
|
480
|
+
if not resolved_result.is_relative_to(run_root):
|
|
481
|
+
raise DispatchError("agent result link must stay inside the current run")
|
|
482
|
+
result_relative = _relative_project_path(project_root, resolved_result)
|
|
483
|
+
team_state_path = resolve_required_path(project_root, manifest, "teamStatePath")
|
|
484
|
+
link = {"dispatchId": dispatch_id, "resultPath": result_relative}
|
|
485
|
+
with _team_state_lock(team_state_path):
|
|
486
|
+
team_state = load_json_object(team_state_path, "team-state")
|
|
487
|
+
dispatches = [
|
|
488
|
+
item
|
|
489
|
+
for collection in (
|
|
490
|
+
team_state.get("agentDispatches") or [],
|
|
491
|
+
team_state.get("workerDispatches") or [],
|
|
492
|
+
)
|
|
493
|
+
for item in collection
|
|
494
|
+
if isinstance(item, Mapping) and item.get("dispatchId") == dispatch_id
|
|
495
|
+
]
|
|
496
|
+
if len(dispatches) != 1:
|
|
497
|
+
raise DispatchError(
|
|
498
|
+
f"agent result link requires exactly one dispatch record: {dispatch_id}"
|
|
499
|
+
)
|
|
500
|
+
links = team_state.setdefault("agentResultLinks", [])
|
|
501
|
+
if not isinstance(links, list):
|
|
502
|
+
raise DispatchError("team-state agentResultLinks must be an array")
|
|
503
|
+
same_result = [
|
|
504
|
+
item for item in links
|
|
505
|
+
if isinstance(item, Mapping) and item.get("resultPath") == result_relative
|
|
506
|
+
]
|
|
507
|
+
if same_result and any(dict(item) != link for item in same_result):
|
|
508
|
+
raise DispatchError(
|
|
509
|
+
f"agent result is already linked to another dispatch: {result_relative}"
|
|
510
|
+
)
|
|
511
|
+
if any(
|
|
512
|
+
isinstance(item, Mapping)
|
|
513
|
+
and item.get("dispatchId") == dispatch_id
|
|
514
|
+
and item.get("resultPath") != result_relative
|
|
515
|
+
for item in links
|
|
516
|
+
):
|
|
517
|
+
raise DispatchError(
|
|
518
|
+
f"agent dispatch is already linked to another result: {dispatch_id}"
|
|
519
|
+
)
|
|
520
|
+
if link not in links:
|
|
521
|
+
links.append(link)
|
|
522
|
+
write_json(team_state_path, team_state)
|
|
523
|
+
return link
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _relative_project_path(project_root: Path, path: Path) -> str:
|
|
527
|
+
try:
|
|
528
|
+
return path.resolve(strict=False).relative_to(project_root).as_posix()
|
|
529
|
+
except ValueError as exc:
|
|
530
|
+
raise DispatchError(f"path is outside project root: {path}") from exc
|
|
209
531
|
|
|
210
532
|
|
|
211
533
|
def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
|
|
@@ -236,31 +558,32 @@ def transition_worker_status(
|
|
|
236
558
|
if status in REASON_REQUIRED_STATUSES and not reason:
|
|
237
559
|
raise DispatchError(f"worker status `{status}` requires a non-empty reason")
|
|
238
560
|
timestamp = _utc_timestamp(at)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
worker
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
561
|
+
with _team_state_lock(team_state_path):
|
|
562
|
+
payload = load_json_object(team_state_path, "team-state")
|
|
563
|
+
workers = payload.get("workers")
|
|
564
|
+
if not isinstance(workers, list):
|
|
565
|
+
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
566
|
+
for worker in workers:
|
|
567
|
+
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
568
|
+
worker["status"] = status
|
|
569
|
+
worker["reason"] = reason
|
|
570
|
+
if status == "in-progress":
|
|
571
|
+
worker["startedAt"] = timestamp
|
|
572
|
+
worker.pop("endedAt", None)
|
|
573
|
+
elif status == "not-run":
|
|
574
|
+
worker.pop("startedAt", None)
|
|
575
|
+
worker.pop("endedAt", None)
|
|
576
|
+
else:
|
|
577
|
+
worker["endedAt"] = timestamp
|
|
578
|
+
if model_execution_value:
|
|
579
|
+
# `model` is the catalog display name the task-manifest
|
|
580
|
+
# declares; only the execution identifier belongs here. Writing
|
|
581
|
+
# both from one value destroys the display name on every worker
|
|
582
|
+
# whose two differ, and `validate-run` compares team-state's
|
|
583
|
+
# `model` against the manifest.
|
|
584
|
+
worker["modelExecutionValue"] = model_execution_value
|
|
585
|
+
write_json(team_state_path, payload)
|
|
586
|
+
return
|
|
264
587
|
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
265
588
|
|
|
266
589
|
|
|
@@ -294,11 +617,12 @@ def record_dispatch_facts(team_state_path: Path, dispatch_mode: str) -> None:
|
|
|
294
617
|
rule out. A marker the launch prompt pre-recorded (the concurrent-run
|
|
295
618
|
`skipped` decision) belongs to that run and is never overwritten.
|
|
296
619
|
"""
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
620
|
+
with _team_state_lock(team_state_path):
|
|
621
|
+
payload = load_json_object(team_state_path, "team-state")
|
|
622
|
+
payload["dispatchMode"] = dispatch_mode
|
|
623
|
+
if not _has_recorded_team_create(payload):
|
|
624
|
+
payload["teamCreate"] = {"attempted": False, "status": "implicit"}
|
|
625
|
+
write_json(team_state_path, payload)
|
|
302
626
|
|
|
303
627
|
|
|
304
628
|
def _has_recorded_team_create(team_state: Mapping[str, Any]) -> bool:
|
|
@@ -324,7 +648,13 @@ def validate_initial_prompts(
|
|
|
324
648
|
manifest: Mapping[str, Any], jobs: Sequence[WorkerJob]
|
|
325
649
|
) -> None:
|
|
326
650
|
records = [
|
|
327
|
-
PromptRecord(
|
|
651
|
+
PromptRecord(
|
|
652
|
+
job.worker_id,
|
|
653
|
+
job.dispatch_kind,
|
|
654
|
+
job.prompt_path,
|
|
655
|
+
metadata_path=(job.prompt_metadata_path if job.invocation_id else None),
|
|
656
|
+
expected_duty_audience=(job.audience if job.invocation_id else None),
|
|
657
|
+
)
|
|
328
658
|
for job in jobs
|
|
329
659
|
]
|
|
330
660
|
errors = validate_initial_prompt_records(
|
|
@@ -342,6 +672,8 @@ def validate_dispatch_prompts(
|
|
|
342
672
|
active_context: Mapping[str, Any],
|
|
343
673
|
jobs: Sequence[WorkerJob],
|
|
344
674
|
) -> None:
|
|
675
|
+
if isinstance(manifest.get("agentContract"), Mapping):
|
|
676
|
+
_validate_agent_invocations(manifest, jobs)
|
|
345
677
|
initial_jobs = [
|
|
346
678
|
job for job in jobs if not job.dispatch_kind.startswith("reverify-r")
|
|
347
679
|
]
|
|
@@ -388,6 +720,80 @@ def validate_dispatch_prompts(
|
|
|
388
720
|
raise DispatchError("reverify prompt contract: " + "; ".join(errors))
|
|
389
721
|
|
|
390
722
|
|
|
723
|
+
def _validate_agent_invocations(
|
|
724
|
+
manifest: Mapping[str, Any],
|
|
725
|
+
jobs: Sequence[WorkerJob],
|
|
726
|
+
) -> None:
|
|
727
|
+
manifest_path_value = require_string(manifest, "runManifestPath")
|
|
728
|
+
assignments = manifest.get("invocationAssignments")
|
|
729
|
+
if not isinstance(assignments, Mapping):
|
|
730
|
+
raise DispatchError("run manifest has no invocationAssignments object")
|
|
731
|
+
errors: list[str] = []
|
|
732
|
+
for job in jobs:
|
|
733
|
+
missing = [
|
|
734
|
+
name
|
|
735
|
+
for name, value in (
|
|
736
|
+
("invocationId", job.invocation_id),
|
|
737
|
+
("audience", job.audience),
|
|
738
|
+
("assignmentRef", job.assignment_ref),
|
|
739
|
+
("promptMetadataPath", str(job.prompt_metadata_path)),
|
|
740
|
+
("catalogDigest", job.catalog_digest),
|
|
741
|
+
("assignmentDigest", job.assignment_digest),
|
|
742
|
+
("dutyDigest", job.duty_digest),
|
|
743
|
+
("instructionDigest", job.instruction_digest),
|
|
744
|
+
("promptDigest", job.prompt_digest),
|
|
745
|
+
("enforcementMode", job.enforcement_mode),
|
|
746
|
+
)
|
|
747
|
+
if not value or value == "."
|
|
748
|
+
]
|
|
749
|
+
if missing:
|
|
750
|
+
errors.append(
|
|
751
|
+
f"{job.worker_id}: agent invocation metadata fields are missing: "
|
|
752
|
+
+ ", ".join(missing)
|
|
753
|
+
)
|
|
754
|
+
continue
|
|
755
|
+
if job.enforcement_mode != "core-pre-dispatch":
|
|
756
|
+
errors.append(
|
|
757
|
+
f"{job.worker_id}: code-owned dispatch requires core-pre-dispatch"
|
|
758
|
+
)
|
|
759
|
+
assignment_payload = assignments.get(job.assignment_ref)
|
|
760
|
+
try:
|
|
761
|
+
assignment = agent_model_assignment_from_payload(assignment_payload)
|
|
762
|
+
except AgentInvocationError as exc:
|
|
763
|
+
errors.append(f"{job.worker_id}: {exc}")
|
|
764
|
+
continue
|
|
765
|
+
if assignment.model_execution_value != job.model_execution_value:
|
|
766
|
+
errors.append(
|
|
767
|
+
f"{job.worker_id}: model execution value does not match invocation assignment"
|
|
768
|
+
)
|
|
769
|
+
if assignment.host_model_value != job.host_model_value:
|
|
770
|
+
errors.append(
|
|
771
|
+
f"{job.worker_id}: host model value does not match invocation assignment"
|
|
772
|
+
)
|
|
773
|
+
manifest_path = resolve_project_path(job.project_root, manifest_path_value)
|
|
774
|
+
verification = verify_agent_invocation(
|
|
775
|
+
job.prompt_metadata_path,
|
|
776
|
+
project_root=job.project_root,
|
|
777
|
+
expected_run_manifest_path=manifest_path,
|
|
778
|
+
expected_assignment=assignment,
|
|
779
|
+
expected_invocation_id=job.invocation_id,
|
|
780
|
+
expected_worker_id=job.worker_id,
|
|
781
|
+
expected_assignment_ref=job.assignment_ref,
|
|
782
|
+
expected_audience=job.audience,
|
|
783
|
+
)
|
|
784
|
+
errors.extend(f"{job.worker_id}: {error}" for error in verification)
|
|
785
|
+
try:
|
|
786
|
+
metadata = load_json_object(job.prompt_metadata_path, "agent metadata")
|
|
787
|
+
except DispatchError as exc:
|
|
788
|
+
errors.append(f"{job.worker_id}: {exc}")
|
|
789
|
+
continue
|
|
790
|
+
digests = metadata.get("digests")
|
|
791
|
+
if not isinstance(digests, Mapping) or dict(digests) != job.digests:
|
|
792
|
+
errors.append(f"{job.worker_id}: job digests do not match agent metadata")
|
|
793
|
+
if errors:
|
|
794
|
+
raise DispatchError("agent invocation contract: " + "; ".join(errors))
|
|
795
|
+
|
|
796
|
+
|
|
391
797
|
def worker_jobs_from_file(
|
|
392
798
|
project_root: Path,
|
|
393
799
|
jobs_file: Path,
|
|
@@ -446,6 +852,11 @@ def _worker_job_from_file(
|
|
|
446
852
|
resolve_project_path(project_root, path)
|
|
447
853
|
for path in string_list(item.get("completionPaths"))
|
|
448
854
|
) or (result_path,)
|
|
855
|
+
digests = item.get("digests")
|
|
856
|
+
digest_values = digests if isinstance(digests, Mapping) else {}
|
|
857
|
+
host_model_value = item.get("hostModelValue")
|
|
858
|
+
if host_model_value is not None and not isinstance(host_model_value, str):
|
|
859
|
+
raise DispatchError("jobs file hostModelValue must be a string or null")
|
|
449
860
|
return WorkerJob(
|
|
450
861
|
worker_id=worker_id,
|
|
451
862
|
provider=provider,
|
|
@@ -461,6 +872,20 @@ def _worker_job_from_file(
|
|
|
461
872
|
role=require_string(item, "role"),
|
|
462
873
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
463
874
|
dispatch_kind=dispatch_kind,
|
|
875
|
+
invocation_id=string_value(item.get("invocationId")),
|
|
876
|
+
audience=string_value(item.get("audience")),
|
|
877
|
+
assignment_ref=string_value(item.get("assignmentRef")),
|
|
878
|
+
prompt_metadata_path=resolve_project_path(
|
|
879
|
+
project_root,
|
|
880
|
+
string_value(item.get("promptMetadataPath")),
|
|
881
|
+
) if string_value(item.get("promptMetadataPath")) else Path(),
|
|
882
|
+
catalog_digest=string_value(digest_values.get("catalogDigest")),
|
|
883
|
+
assignment_digest=string_value(digest_values.get("assignmentDigest")),
|
|
884
|
+
duty_digest=string_value(digest_values.get("dutyDigest")),
|
|
885
|
+
instruction_digest=string_value(digest_values.get("instructionDigest")),
|
|
886
|
+
prompt_digest=string_value(digest_values.get("promptDigest")),
|
|
887
|
+
host_model_value=host_model_value,
|
|
888
|
+
enforcement_mode=string_value(item.get("enforcementMode")),
|
|
464
889
|
)
|
|
465
890
|
|
|
466
891
|
|