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.
Files changed (102) hide show
  1. package/README.md +6 -5
  2. package/docs/architecture/storage-model.md +57 -1
  3. package/docs/architecture.md +70 -2
  4. package/docs/cli.md +8 -4
  5. package/docs/for-ai/skills/okstra-code-review.md +3 -2
  6. package/docs/for-ai/skills/okstra-schedule-gen.md +3 -1
  7. package/docs/pr-template-usage.md +10 -6
  8. package/docs/project-structure-overview.md +14 -11
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/claude-worker.md +6 -5
  12. package/runtime/agents/workers/report-writer-worker.md +9 -4
  13. package/runtime/agents/workers/translator-worker.md +6 -4
  14. package/runtime/prompts/coding-preflight/architectures/hexagonal.md +27 -1
  15. package/runtime/prompts/coding-preflight/clean-code.md +13 -0
  16. package/runtime/prompts/duties/acceptance-critic.md +24 -0
  17. package/runtime/prompts/duties/acceptance-verifier.md +24 -0
  18. package/runtime/prompts/duties/analysis-worker.md +24 -0
  19. package/runtime/prompts/duties/code-reviewer.md +24 -0
  20. package/runtime/prompts/duties/common.md +35 -0
  21. package/runtime/prompts/duties/implementation-executor.md +24 -0
  22. package/runtime/prompts/duties/implementation-verifier.md +24 -0
  23. package/runtime/prompts/duties/lead.md +24 -0
  24. package/runtime/prompts/duties/report-writer.md +24 -0
  25. package/runtime/prompts/duties/reverification-worker.md +24 -0
  26. package/runtime/prompts/duties/schedule-verifier.md +24 -0
  27. package/runtime/prompts/duties/scope-critic.md +24 -0
  28. package/runtime/prompts/duties/translator.md +24 -0
  29. package/runtime/prompts/lead/convergence.md +51 -7
  30. package/runtime/prompts/lead/okstra-lead-contract.md +10 -20
  31. package/runtime/prompts/lead/plan-body-verification.md +16 -1
  32. package/runtime/prompts/lead/report-writer.md +20 -5
  33. package/runtime/prompts/lead/team-contract.md +13 -13
  34. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  36. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  37. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  38. package/runtime/prompts/profiles/implementation.md +4 -2
  39. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +6 -0
  40. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +3 -2
  41. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +8 -0
  42. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +33 -0
  43. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +13 -12
  44. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +6 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +3 -2
  46. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +2 -0
  47. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  48. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +6 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -1
  50. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +6 -0
  51. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -1
  52. package/runtime/python/okstra_ctl/agent_invocation.py +1502 -0
  53. package/runtime/python/okstra_ctl/agent_prompt_cli.py +788 -0
  54. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -107
  55. package/runtime/python/okstra_ctl/context_cost.py +46 -5
  56. package/runtime/python/okstra_ctl/dispatch_core.py +312 -37
  57. package/runtime/python/okstra_ctl/dispatch_state.py +461 -36
  58. package/runtime/python/okstra_ctl/doctor.py +150 -16
  59. package/runtime/python/okstra_ctl/entrypoints/hosts.py +87 -9
  60. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +214 -23
  61. package/runtime/python/okstra_ctl/path_hints.py +26 -0
  62. package/runtime/python/okstra_ctl/paths.py +20 -0
  63. package/runtime/python/okstra_ctl/ports/__init__.py +8 -0
  64. package/runtime/python/okstra_ctl/ports/host.py +3 -0
  65. package/runtime/python/okstra_ctl/ports/host_model.py +60 -0
  66. package/runtime/python/okstra_ctl/pr_template.py +3 -6
  67. package/runtime/python/okstra_ctl/registry/host_registry.py +5 -0
  68. package/runtime/python/okstra_ctl/render.py +217 -12
  69. package/runtime/python/okstra_ctl/report_finalize.py +44 -0
  70. package/runtime/python/okstra_ctl/run.py +368 -51
  71. package/runtime/python/okstra_ctl/session.py +16 -12
  72. package/runtime/python/okstra_ctl/team.py +11 -11
  73. package/runtime/python/okstra_ctl/worker_dispatch.py +104 -0
  74. package/runtime/python/okstra_ctl/worker_prompt_body.py +5 -38
  75. package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -2
  76. package/runtime/python/okstra_ctl/worker_prompt_policy.py +38 -1
  77. package/runtime/skills/okstra-code-review/SKILL.md +22 -3
  78. package/runtime/skills/okstra-run/SKILL.md +16 -1
  79. package/runtime/skills/okstra-schedule-gen/SKILL.md +15 -1
  80. package/runtime/templates/implementation-worker-preamble.md +0 -10
  81. package/runtime/templates/report-writer-prompt-preamble.md +0 -9
  82. package/runtime/templates/reports/settings.template.json +0 -11
  83. package/runtime/templates/worker-prompt-preamble.md +0 -10
  84. package/runtime/validators/lib/fixtures.sh +93 -0
  85. package/runtime/validators/lib/validate-assets.sh +0 -8
  86. package/runtime/validators/validate-run.py +182 -0
  87. package/src/cli-registry.mjs +14 -0
  88. package/src/commands/execute/agent-prompt.mjs +25 -0
  89. package/src/commands/execute/codex-dispatch.mjs +6 -63
  90. package/src/commands/execute/worker-dispatch.mjs +76 -0
  91. package/src/commands/lifecycle/doctor.mjs +18 -3
  92. package/src/commands/lifecycle/install.mjs +33 -15
  93. package/src/commands/lifecycle/uninstall.mjs +4 -3
  94. package/src/lib/install-assets.mjs +9 -0
  95. package/runtime/agents/workers/antigravity-worker.md +0 -259
  96. package/runtime/agents/workers/codex-worker.md +0 -259
  97. package/runtime/agents/workers/grok-worker.md +0 -259
  98. package/runtime/agents/workers/kimi-worker.md +0 -259
  99. package/runtime/prompts/coding-preflight/scripts/preedit-check.sh +0 -79
  100. package/runtime/templates/operating-standard.md +0 -22
  101. package/src/lib/worker-agent-render.mjs +0 -50
  102. /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
- return {
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
- tmp = path.with_suffix(path.suffix + ".tmp")
205
- tmp.write_text(
206
- json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
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
- os.replace(tmp, path)
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
- payload = load_json_object(team_state_path, "team-state")
240
- workers = payload.get("workers")
241
- if not isinstance(workers, list):
242
- raise DispatchError(f"team-state workers must be an array: {team_state_path}")
243
- for worker in workers:
244
- if isinstance(worker, dict) and worker.get("workerId") == worker_id:
245
- worker["status"] = status
246
- worker["reason"] = reason
247
- if status == "in-progress":
248
- worker["startedAt"] = timestamp
249
- worker.pop("endedAt", None)
250
- elif status == "not-run":
251
- worker.pop("startedAt", None)
252
- worker.pop("endedAt", None)
253
- else:
254
- worker["endedAt"] = timestamp
255
- if model_execution_value:
256
- # `model` is the catalog display name the task-manifest
257
- # declares; only the execution identifier belongs here. Writing
258
- # both from one value destroys the display name on every worker
259
- # whose two differ, and `validate-run` compares team-state's
260
- # `model` against the manifest.
261
- worker["modelExecutionValue"] = model_execution_value
262
- write_json(team_state_path, payload)
263
- return
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
- payload = load_json_object(team_state_path, "team-state")
298
- payload["dispatchMode"] = dispatch_mode
299
- if not _has_recorded_team_create(payload):
300
- payload["teamCreate"] = {"attempted": False, "status": "implicit"}
301
- write_json(team_state_path, payload)
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(job.worker_id, job.dispatch_kind, job.prompt_path)
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