pdl-taskmaster 2.4.0__py3-none-any.whl
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.
- pdl_taskmaster/__init__.py +6 -0
- pdl_taskmaster/contracts/AUTHORITY_MAP.json +19 -0
- pdl_taskmaster/contracts/CALIBRATION.json +7 -0
- pdl_taskmaster/contracts/CONTRACT_MANIFEST.json +231 -0
- pdl_taskmaster/contracts/EXECUTION_CONTRACT.json +615 -0
- pdl_taskmaster/contracts/IMPLEMENTATION_BOUNDARY.json +32 -0
- pdl_taskmaster/contracts/VERIFICATION_CONTRACT.json +379 -0
- pdl_taskmaster/contracts/standards/ARTIFACT_STANDARD.md +7 -0
- pdl_taskmaster/contracts/standards/AUTHORITY_STANDARD.md +17 -0
- pdl_taskmaster/contracts/standards/CONFORMANCE_STANDARD.md +15 -0
- pdl_taskmaster/contracts/standards/CONTEXT_STANDARD.md +15 -0
- pdl_taskmaster/contracts/standards/EXECUTION_STANDARD.md +14 -0
- pdl_taskmaster/contracts/standards/HANDOFF_STANDARD.md +21 -0
- pdl_taskmaster/contracts/standards/INSTALLATION_STANDARD.md +17 -0
- pdl_taskmaster/contracts/standards/PDL_STANDARD.md +19 -0
- pdl_taskmaster/contracts/standards/PROMPT_STANDARD.md +13 -0
- pdl_taskmaster/contracts/standards/PROTOCOL_STANDARD.md +17 -0
- pdl_taskmaster/contracts/standards/RESPONSE_PLAN_STANDARD.md +24 -0
- pdl_taskmaster/contracts/standards/RESULT_STANDARD.md +35 -0
- pdl_taskmaster/contracts/standards/REVIEW_STANDARD.md +31 -0
- pdl_taskmaster/contracts/standards/SEMANTIC_INPUT_STANDARD.md +15 -0
- pdl_taskmaster/contracts/standards/TASK_SEMANTICS_STANDARD.md +9 -0
- pdl_taskmaster/controller/__init__.py +0 -0
- pdl_taskmaster/controller/mechanical_controller.py +542 -0
- pdl_taskmaster/controller/schemas/activation_decision.schema.json +34 -0
- pdl_taskmaster/controller/schemas/artifact_body.schema.json +14 -0
- pdl_taskmaster/controller/schemas/artifact_review_facts.schema.json +91 -0
- pdl_taskmaster/controller/schemas/bootstrap_analysis.schema.json +60 -0
- pdl_taskmaster/controller/schemas/execution_draft.schema.json +47 -0
- pdl_taskmaster/controller/schemas/execution_input_decision.schema.json +43 -0
- pdl_taskmaster/controller/schemas/execution_outcome.schema.json +201 -0
- pdl_taskmaster/controller/schemas/plan_artifact.schema.json +14 -0
- pdl_taskmaster/controller/schemas/prompt_artifact.schema.json +63 -0
- pdl_taskmaster/controller/schemas/protocol_discussion.schema.json +8 -0
- pdl_taskmaster/controller/schemas/result_ir_repair.schema.json +140 -0
- pdl_taskmaster/eval/__init__.py +1 -0
- pdl_taskmaster/eval/build_adversarial_battery.py +351 -0
- pdl_taskmaster/eval/compare_eval_runs.py +251 -0
- pdl_taskmaster/eval/control_api_call.py +428 -0
- pdl_taskmaster/eval/export_rlcd_dataset.py +799 -0
- pdl_taskmaster/eval/fidelity_scan.py +268 -0
- pdl_taskmaster/eval/gen_fidelity_battery.py +457 -0
- pdl_taskmaster/eval/leak_scan.py +260 -0
- pdl_taskmaster/eval/rerecord_fixtures.py +139 -0
- pdl_taskmaster/eval/run_canary_shape_probe.py +229 -0
- pdl_taskmaster/eval/run_dual_gate_probe.py +404 -0
- pdl_taskmaster/eval/run_qualified_batch.py +841 -0
- pdl_taskmaster/eval/summarize_run.py +194 -0
- pdl_taskmaster/host/__init__.py +1 -0
- pdl_taskmaster/host/app.py +164 -0
- pdl_taskmaster/host/cli.py +100 -0
- pdl_taskmaster/host/repl.py +866 -0
- pdl_taskmaster/observation/__init__.py +1 -0
- pdl_taskmaster/observation/cli.py +47 -0
- pdl_taskmaster/observation/observed_session.py +219 -0
- pdl_taskmaster/observation/parsers.py +66 -0
- pdl_taskmaster/observation/records.py +58 -0
- pdl_taskmaster/observation/session.py +3 -0
- pdl_taskmaster/observation/sink.py +3 -0
- pdl_taskmaster/observation/sinks.py +20 -0
- pdl_taskmaster/providers/README.md +66 -0
- pdl_taskmaster/providers/__init__.py +1 -0
- pdl_taskmaster/providers/api_worker.py +411 -0
- pdl_taskmaster/providers/base.py +26 -0
- pdl_taskmaster/providers/codex_worker.py +328 -0
- pdl_taskmaster/providers/fixtures.py +157 -0
- pdl_taskmaster/providers/live_stub.py +66 -0
- pdl_taskmaster/providers/recorded.py +120 -0
- pdl_taskmaster/py.typed +1 -0
- pdl_taskmaster/runtime/__init__.py +0 -0
- pdl_taskmaster/runtime/context_compiler.py +151 -0
- pdl_taskmaster/runtime/model_classification.py +290 -0
- pdl_taskmaster/runtime/normative_store.py +157 -0
- pdl_taskmaster/runtime/operation_bridge.py +336 -0
- pdl_taskmaster/runtime/presentation.py +18 -0
- pdl_taskmaster/runtime/quarantine.py +91 -0
- pdl_taskmaster/runtime/result_ir.py +401 -0
- pdl_taskmaster/runtime/semantic_observer.py +40 -0
- pdl_taskmaster/runtime/session_engine.py +1168 -0
- pdl_taskmaster/runtime/standard_registry.py +55 -0
- pdl_taskmaster/runtime/wire_payloads.py +462 -0
- pdl_taskmaster/runtime/worker-bootstrap.txt +4 -0
- pdl_taskmaster/runtime/workspace.py +602 -0
- pdl_taskmaster/tracking/__init__.py +1 -0
- pdl_taskmaster/tracking/log_live_session.py +98 -0
- pdl_taskmaster/tracking/mlflow_sink.py +58 -0
- pdl_taskmaster/verify/verify_repl_baseline.py +299 -0
- pdl_taskmaster-2.4.0.dist-info/METADATA +155 -0
- pdl_taskmaster-2.4.0.dist-info/RECORD +92 -0
- pdl_taskmaster-2.4.0.dist-info/WHEEL +4 -0
- pdl_taskmaster-2.4.0.dist-info/entry_points.txt +2 -0
- pdl_taskmaster-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkspaceError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class WorkspaceInvocation:
|
|
20
|
+
operation: str
|
|
21
|
+
stage: str
|
|
22
|
+
invocation_id: str
|
|
23
|
+
input_dir: Path
|
|
24
|
+
output_dir: Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkspaceRun:
|
|
28
|
+
"""Filesystem-backed ICM context-flow workspace for one candidate session.
|
|
29
|
+
|
|
30
|
+
The workspace is an orchestration and handoff substrate only. It does not
|
|
31
|
+
define protocol correctness. Standard Contracts remain the sole normative
|
|
32
|
+
authority; the Execution Contract selects which Standard IDs and working
|
|
33
|
+
artifacts are exposed to each semantic operation.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
SCHEMA = "C0-ICM-WORKSPACE-5"
|
|
37
|
+
|
|
38
|
+
def __init__(self, repo_root: Path, path: Path):
|
|
39
|
+
self.repo_root = repo_root
|
|
40
|
+
self.path = path
|
|
41
|
+
self.metadata_path = self.path / "workspace.json"
|
|
42
|
+
if not self.metadata_path.is_file():
|
|
43
|
+
raise WorkspaceError("workspace_metadata_missing")
|
|
44
|
+
self.metadata = json.loads(self.metadata_path.read_text(encoding="utf-8"))
|
|
45
|
+
if self.metadata.get("schema_version") != self.SCHEMA:
|
|
46
|
+
raise WorkspaceError("workspace_schema")
|
|
47
|
+
# Session-hierarchy mode (ADR-0008 / Phase 9 S3): when turn_id is set,
|
|
48
|
+
# turn-scoped state (controller state, events, stages) lives under
|
|
49
|
+
# turns/<turn_id>/ while shared/ and workspace.json stay session-level.
|
|
50
|
+
# Legacy single-turn workspaces (turn_id None) keep the flat layout.
|
|
51
|
+
self.turn_id: str | None = self.metadata.get("turn_id")
|
|
52
|
+
from pdl_taskmaster.runtime.normative_store import NormativeStore
|
|
53
|
+
contract_path = NormativeStore.resolve_contract(self.repo_root, "EXECUTION_CONTRACT.json")
|
|
54
|
+
if not contract_path.is_file():
|
|
55
|
+
contract_path = self.repo_root / "contracts" / "EXECUTION_CONTRACT.json"
|
|
56
|
+
self.execution_contract = json.loads(contract_path.read_text(encoding="utf-8"))
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def create(
|
|
60
|
+
cls,
|
|
61
|
+
repo_root: str | Path,
|
|
62
|
+
workspace_root: str | Path,
|
|
63
|
+
*,
|
|
64
|
+
turn_id: str | None = None,
|
|
65
|
+
) -> "WorkspaceRun":
|
|
66
|
+
repo_root = Path(repo_root)
|
|
67
|
+
workspace_root = Path(workspace_root)
|
|
68
|
+
workspace_root.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
workspace_id = f"W-{uuid.uuid4().hex[:12]}"
|
|
70
|
+
path = workspace_root / workspace_id
|
|
71
|
+
if path.exists():
|
|
72
|
+
raise WorkspaceError("workspace_collision")
|
|
73
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
if turn_id is None:
|
|
75
|
+
(path / "state").mkdir(exist_ok=True)
|
|
76
|
+
(path / "events").mkdir(exist_ok=True)
|
|
77
|
+
(path / "stages").mkdir(exist_ok=True)
|
|
78
|
+
(path / "shared").mkdir(exist_ok=True)
|
|
79
|
+
else:
|
|
80
|
+
# Session-hierarchy mode (S3): session-level dirs plus the first
|
|
81
|
+
# turn's scoped tree. Stage directories materialize on demand.
|
|
82
|
+
(path / "shared").mkdir(exist_ok=True)
|
|
83
|
+
cls._scaffold_turn(path, turn_id)
|
|
84
|
+
metadata = {
|
|
85
|
+
"schema_version": cls.SCHEMA,
|
|
86
|
+
"workspace_id": workspace_id,
|
|
87
|
+
"protocol_instance_id": None,
|
|
88
|
+
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
89
|
+
"icm_reference": "arXiv:2603.16021v2",
|
|
90
|
+
"context_flow": "filesystem_stage_handoffs",
|
|
91
|
+
"control_flow": "mechanical_controller",
|
|
92
|
+
}
|
|
93
|
+
if turn_id is not None:
|
|
94
|
+
metadata["turn_id"] = turn_id
|
|
95
|
+
metadata["session_hierarchy"] = True
|
|
96
|
+
cls._atomic_write(path / "workspace.json", json.dumps(metadata, ensure_ascii=False, indent=2) + "\n")
|
|
97
|
+
run = cls(repo_root, path)
|
|
98
|
+
run.append_event("WORKSPACE_CREATED", {"workspace_id": workspace_id, "turn_id": turn_id})
|
|
99
|
+
return run
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def _scaffold_turn(cls, path: Path, turn_id: str) -> Path:
|
|
103
|
+
turn_dir = path / "turns" / turn_id
|
|
104
|
+
if turn_dir.exists():
|
|
105
|
+
raise WorkspaceError(f"turn_exists:{turn_id}")
|
|
106
|
+
(turn_dir / "state").mkdir(parents=True, exist_ok=False)
|
|
107
|
+
(turn_dir / "events").mkdir(parents=True, exist_ok=False)
|
|
108
|
+
(turn_dir / "stages").mkdir(parents=True, exist_ok=False)
|
|
109
|
+
cls._atomic_write(
|
|
110
|
+
turn_dir / "turn.json",
|
|
111
|
+
json.dumps(
|
|
112
|
+
{
|
|
113
|
+
"turn_id": turn_id,
|
|
114
|
+
"status": "ACTIVE",
|
|
115
|
+
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
116
|
+
},
|
|
117
|
+
ensure_ascii=False,
|
|
118
|
+
indent=2,
|
|
119
|
+
)
|
|
120
|
+
+ "\n",
|
|
121
|
+
)
|
|
122
|
+
return turn_dir
|
|
123
|
+
|
|
124
|
+
# -- Session-hierarchy helpers (S3/S4) ---------------------------------
|
|
125
|
+
|
|
126
|
+
def _turn_base(self) -> Path:
|
|
127
|
+
"""Root for turn-scoped state: turns/<turn_id> in hierarchy mode,
|
|
128
|
+
the workspace root in legacy flat mode."""
|
|
129
|
+
return self.path / "turns" / self.turn_id if self.turn_id else self.path
|
|
130
|
+
|
|
131
|
+
def stages_root(self) -> Path:
|
|
132
|
+
"""Public root for this workspace's stage tree (turn-scoped in
|
|
133
|
+
hierarchy mode; workspace root in legacy flat mode). Readers that
|
|
134
|
+
walk stage output must use this instead of path/"stages" so both
|
|
135
|
+
layouts stay supported."""
|
|
136
|
+
return self._turn_base() / "stages"
|
|
137
|
+
|
|
138
|
+
def start_turn(self, new_turn_id: str) -> None:
|
|
139
|
+
"""Open a new turn in this session workspace and move the pointer.
|
|
140
|
+
|
|
141
|
+
The prior turn's scoped state (controller state, events, stages) is
|
|
142
|
+
preserved verbatim; only the pointer moves. Per ADR-0008 S4, the new
|
|
143
|
+
turn starts clean: prior drafts, rejected plans, and review dialogue
|
|
144
|
+
are never carried forward except through previous_deliverable()."""
|
|
145
|
+
if self.turn_id is None:
|
|
146
|
+
raise WorkspaceError("session_hierarchy_required")
|
|
147
|
+
if new_turn_id == self.turn_id:
|
|
148
|
+
raise WorkspaceError("turn_pointer_unchanged")
|
|
149
|
+
self._scaffold_turn(self.path, new_turn_id)
|
|
150
|
+
self.metadata["turn_id"] = new_turn_id
|
|
151
|
+
self._write(
|
|
152
|
+
self.metadata_path,
|
|
153
|
+
json.dumps(self.metadata, ensure_ascii=False, indent=2) + "\n",
|
|
154
|
+
)
|
|
155
|
+
self.turn_id = new_turn_id
|
|
156
|
+
|
|
157
|
+
def read_turn_status(self, turn_id: str) -> dict[str, Any]:
|
|
158
|
+
path = self.path / "turns" / turn_id / "turn.json"
|
|
159
|
+
if not path.is_file():
|
|
160
|
+
raise WorkspaceError(f"turn_missing:{turn_id}")
|
|
161
|
+
return json.loads(self._read(path))
|
|
162
|
+
|
|
163
|
+
def mark_turn_status(self, status: str, *, deliverable_sha256: str | None = None) -> None:
|
|
164
|
+
if self.turn_id is None:
|
|
165
|
+
raise WorkspaceError("session_hierarchy_required")
|
|
166
|
+
path = self.path / "turns" / self.turn_id / "turn.json"
|
|
167
|
+
data = json.loads(self._read(path)) if path.is_file() else {"turn_id": self.turn_id}
|
|
168
|
+
data["status"] = status
|
|
169
|
+
if deliverable_sha256 is not None:
|
|
170
|
+
data["deliverable_sha256"] = deliverable_sha256
|
|
171
|
+
data["updated_at_utc"] = datetime.now(timezone.utc).isoformat()
|
|
172
|
+
self._write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
173
|
+
self.append_event("TURN_STATUS", {"turn_id": self.turn_id, "status": status})
|
|
174
|
+
|
|
175
|
+
def next_turn_id(self) -> str:
|
|
176
|
+
"""Next free turn identifier (turn_001, turn_002, ...) for this workspace."""
|
|
177
|
+
turns_dir = self.path / "turns"
|
|
178
|
+
highest = 0
|
|
179
|
+
if turns_dir.is_dir():
|
|
180
|
+
for turn_dir in turns_dir.iterdir():
|
|
181
|
+
m = re.match(r"^turn_(\d+)$", turn_dir.name)
|
|
182
|
+
if m:
|
|
183
|
+
highest = max(highest, int(m.group(1)))
|
|
184
|
+
return f"turn_{highest + 1:03d}"
|
|
185
|
+
|
|
186
|
+
def closed_turns(self) -> list[dict[str, Any]]:
|
|
187
|
+
"""All turns with a terminal status, in turn order."""
|
|
188
|
+
turns_dir = self.path / "turns"
|
|
189
|
+
if not turns_dir.is_dir():
|
|
190
|
+
return []
|
|
191
|
+
out = []
|
|
192
|
+
for turn_dir in sorted(turns_dir.iterdir()):
|
|
193
|
+
marker = turn_dir / "turn.json"
|
|
194
|
+
if marker.is_file():
|
|
195
|
+
data = json.loads(marker.read_text(encoding="utf-8"))
|
|
196
|
+
if data.get("status") in {"CLOSED_SUCCESS", "CLOSED_CANCELLED"}:
|
|
197
|
+
out.append(data)
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
def previous_deliverable(self) -> str | None:
|
|
201
|
+
"""S4: the confirmed deliverable of the most recent CLOSED_SUCCESS turn.
|
|
202
|
+
|
|
203
|
+
Reads ONLY that turn's published execution artifact
|
|
204
|
+
(turns/<id>/stages/50_execution/output/current.md). Drafts, rejected
|
|
205
|
+
plans, and review dialogue are structurally unreachable from here.
|
|
206
|
+
Returns None when no prior turn has closed successfully."""
|
|
207
|
+
closed = [t for t in self.closed_turns() if t.get("status") == "CLOSED_SUCCESS"]
|
|
208
|
+
if not closed:
|
|
209
|
+
return None
|
|
210
|
+
turn_id = closed[-1].get("turn_id")
|
|
211
|
+
body = self.path / "turns" / str(turn_id) / "stages" / "50_execution" / "output" / "current.md"
|
|
212
|
+
if not body.is_file():
|
|
213
|
+
return None
|
|
214
|
+
return body.read_text(encoding="utf-8").rstrip("\n")
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def open(cls, repo_root: str | Path, path: str | Path) -> "WorkspaceRun":
|
|
218
|
+
return cls(Path(repo_root), Path(path))
|
|
219
|
+
|
|
220
|
+
def _write(self, path: Path, content: str) -> None:
|
|
221
|
+
self._atomic_write(path, content)
|
|
222
|
+
|
|
223
|
+
def _read(self, path: Path) -> str:
|
|
224
|
+
return path.read_text(encoding="utf-8")
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
228
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent)
|
|
230
|
+
try:
|
|
231
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
232
|
+
handle.write(content)
|
|
233
|
+
handle.flush()
|
|
234
|
+
os.fsync(handle.fileno())
|
|
235
|
+
os.replace(tmp, path)
|
|
236
|
+
finally:
|
|
237
|
+
if os.path.exists(tmp):
|
|
238
|
+
os.unlink(tmp)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _serialize_symbol(value: Any) -> tuple[str, str]:
|
|
242
|
+
if isinstance(value, str):
|
|
243
|
+
return ".md", value.rstrip() + "\n"
|
|
244
|
+
return ".json", json.dumps(value, ensure_ascii=False, indent=2) + "\n"
|
|
245
|
+
|
|
246
|
+
def _deserialize_symbol(self, path: Path) -> Any:
|
|
247
|
+
content = self._read(path)
|
|
248
|
+
if path.suffix == ".json":
|
|
249
|
+
return json.loads(content)
|
|
250
|
+
return content.rstrip("\n")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def append_event(self, kind: str, payload: dict[str, Any]) -> None:
|
|
254
|
+
event = {
|
|
255
|
+
"at_utc": datetime.now(timezone.utc).isoformat(),
|
|
256
|
+
"kind": kind,
|
|
257
|
+
"payload": payload,
|
|
258
|
+
}
|
|
259
|
+
self._record_event(event)
|
|
260
|
+
|
|
261
|
+
def _record_event(self, event: dict[str, Any]) -> None:
|
|
262
|
+
events = self._turn_base() / "events" / "events.jsonl"
|
|
263
|
+
events.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
with events.open("a", encoding="utf-8", newline="\n") as handle:
|
|
265
|
+
handle.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
266
|
+
handle.flush()
|
|
267
|
+
os.fsync(handle.fileno())
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def controller_state_path(self) -> Path:
|
|
271
|
+
return self._turn_base() / "state" / "controller-state.json"
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def protocol_instance_id(self) -> str | None:
|
|
275
|
+
value = self.metadata.get("protocol_instance_id")
|
|
276
|
+
return value if isinstance(value, str) else None
|
|
277
|
+
|
|
278
|
+
def bind_protocol(self, instance_id: str) -> None:
|
|
279
|
+
current = self.protocol_instance_id
|
|
280
|
+
if self.turn_id is not None:
|
|
281
|
+
# Session-hierarchy mode: each turn runs its own protocol lifecycle,
|
|
282
|
+
# so re-binding across turns is expected. Record the instance on
|
|
283
|
+
# the current turn; the root pointer tracks the latest binding.
|
|
284
|
+
turn_json = self.path / "turns" / self.turn_id / "turn.json"
|
|
285
|
+
data = json.loads(self._read(turn_json)) if turn_json.is_file() else {"turn_id": self.turn_id}
|
|
286
|
+
data["protocol_instance_id"] = instance_id
|
|
287
|
+
self._write(turn_json, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
288
|
+
self.metadata["protocol_instance_id"] = instance_id
|
|
289
|
+
self._write(
|
|
290
|
+
self.metadata_path,
|
|
291
|
+
json.dumps(self.metadata, ensure_ascii=False, indent=2) + "\n",
|
|
292
|
+
)
|
|
293
|
+
self.append_event("PROTOCOL_BOUND", {"instance_id": instance_id, "turn_id": self.turn_id})
|
|
294
|
+
return
|
|
295
|
+
if current is not None and current != instance_id:
|
|
296
|
+
raise WorkspaceError("workspace_already_bound")
|
|
297
|
+
self.metadata["protocol_instance_id"] = instance_id
|
|
298
|
+
self._write(
|
|
299
|
+
self.metadata_path,
|
|
300
|
+
json.dumps(self.metadata, ensure_ascii=False, indent=2) + "\n",
|
|
301
|
+
)
|
|
302
|
+
self.append_event("PROTOCOL_BOUND", {"instance_id": instance_id})
|
|
303
|
+
|
|
304
|
+
def _stage_for(self, operation: str, values: dict[str, Any]) -> str:
|
|
305
|
+
try:
|
|
306
|
+
spec = self.execution_contract["operations"][operation]
|
|
307
|
+
except KeyError as exc:
|
|
308
|
+
raise WorkspaceError(f"operation:{operation}") from exc
|
|
309
|
+
stage = spec.get("workspace_stage")
|
|
310
|
+
selectors = spec.get("workspace_stage_by")
|
|
311
|
+
if selectors:
|
|
312
|
+
symbol = selectors.get("symbol")
|
|
313
|
+
mapping = selectors.get("mapping", {})
|
|
314
|
+
selected = mapping.get(values.get(symbol))
|
|
315
|
+
if not selected:
|
|
316
|
+
raise WorkspaceError(f"stage_selector:{operation}:{values.get(symbol)}")
|
|
317
|
+
stage = selected
|
|
318
|
+
stage_dir = self._turn_base() / "stages" / stage
|
|
319
|
+
stage_dir.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
return stage
|
|
321
|
+
|
|
322
|
+
def _next_invocation_id(self, stage: str, operation: str) -> str:
|
|
323
|
+
counter_path = self._turn_base() / "state" / "invocation-counter.json"
|
|
324
|
+
if counter_path.is_file():
|
|
325
|
+
value = json.loads(self._read(counter_path))
|
|
326
|
+
counter = int(value.get("counter", 0)) + 1
|
|
327
|
+
else:
|
|
328
|
+
counter = 1
|
|
329
|
+
self._write(counter_path, json.dumps({"counter": counter}, indent=2) + "\n")
|
|
330
|
+
return f"{counter:04d}-{operation.lower()}"
|
|
331
|
+
|
|
332
|
+
def materialize_operation(
|
|
333
|
+
self,
|
|
334
|
+
operation: str,
|
|
335
|
+
values: dict[str, Any],
|
|
336
|
+
*,
|
|
337
|
+
higher_priority_constraints: Any = None,
|
|
338
|
+
) -> WorkspaceInvocation:
|
|
339
|
+
stage = self._stage_for(operation, values)
|
|
340
|
+
invocation_id = self._next_invocation_id(stage, operation)
|
|
341
|
+
stage_path = self._turn_base() / "stages" / stage
|
|
342
|
+
input_dir = stage_path / "input" / invocation_id
|
|
343
|
+
output_dir = stage_path / "output" / invocation_id
|
|
344
|
+
input_dir.mkdir(parents=True, exist_ok=False)
|
|
345
|
+
output_dir.mkdir(parents=True, exist_ok=False)
|
|
346
|
+
|
|
347
|
+
index: dict[str, str] = {}
|
|
348
|
+
for symbol, value in values.items():
|
|
349
|
+
suffix, content = self._serialize_symbol(value)
|
|
350
|
+
filename = symbol.lower() + suffix
|
|
351
|
+
self._write(input_dir / filename, content)
|
|
352
|
+
index[symbol] = filename
|
|
353
|
+
if higher_priority_constraints is not None:
|
|
354
|
+
suffix, content = self._serialize_symbol(higher_priority_constraints)
|
|
355
|
+
filename = "higher_priority_constraints" + suffix
|
|
356
|
+
self._write(input_dir / filename, content)
|
|
357
|
+
index["HIGHER_PRIORITY_CONSTRAINTS"] = filename
|
|
358
|
+
self._write(input_dir / "index.json", json.dumps(index, ensure_ascii=False, indent=2) + "\n")
|
|
359
|
+
self._write(
|
|
360
|
+
input_dir / "invocation.json",
|
|
361
|
+
json.dumps(
|
|
362
|
+
{
|
|
363
|
+
"operation": operation,
|
|
364
|
+
"stage": stage,
|
|
365
|
+
"invocation_id": invocation_id,
|
|
366
|
+
"input_symbols": list(values),
|
|
367
|
+
},
|
|
368
|
+
ensure_ascii=False,
|
|
369
|
+
indent=2,
|
|
370
|
+
)
|
|
371
|
+
+ "\n",
|
|
372
|
+
)
|
|
373
|
+
self.append_event(
|
|
374
|
+
"OPERATION_MATERIALIZED",
|
|
375
|
+
{"operation": operation, "stage": stage, "invocation_id": invocation_id},
|
|
376
|
+
)
|
|
377
|
+
return WorkspaceInvocation(operation, stage, invocation_id, input_dir, output_dir)
|
|
378
|
+
|
|
379
|
+
def load_operation_values(self, invocation: WorkspaceInvocation) -> tuple[dict[str, Any], Any]:
|
|
380
|
+
index = json.loads(self._read(invocation.input_dir / "index.json"))
|
|
381
|
+
values: dict[str, Any] = {}
|
|
382
|
+
higher_priority = None
|
|
383
|
+
for symbol, filename in index.items():
|
|
384
|
+
value = self._deserialize_symbol(invocation.input_dir / filename)
|
|
385
|
+
if symbol == "HIGHER_PRIORITY_CONSTRAINTS":
|
|
386
|
+
higher_priority = value
|
|
387
|
+
else:
|
|
388
|
+
values[symbol] = value
|
|
389
|
+
return values, higher_priority
|
|
390
|
+
|
|
391
|
+
def record_projection(self, invocation: WorkspaceInvocation, manifest: dict[str, Any], document: dict[str, Any]) -> None:
|
|
392
|
+
self._write(
|
|
393
|
+
invocation.output_dir / "projection-manifest.json",
|
|
394
|
+
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
|
395
|
+
)
|
|
396
|
+
self._write(
|
|
397
|
+
invocation.output_dir / "compiled-projection.json",
|
|
398
|
+
json.dumps(document, ensure_ascii=False, indent=2) + "\n",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def record_model_output(self, invocation: WorkspaceInvocation, model_text: str) -> None:
|
|
402
|
+
self._write(invocation.output_dir / "model-response.txt", model_text.rstrip() + "\n")
|
|
403
|
+
self.append_event(
|
|
404
|
+
"MODEL_OUTPUT_RECORDED",
|
|
405
|
+
{"operation": invocation.operation, "stage": invocation.stage, "invocation_id": invocation.invocation_id},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def record_parsed_output(self, invocation: WorkspaceInvocation, value: Any) -> None:
|
|
409
|
+
self._write(
|
|
410
|
+
invocation.output_dir / "parsed-output.json",
|
|
411
|
+
json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def _artifact_stage(self, kind: str) -> Path:
|
|
415
|
+
if kind == "prompt":
|
|
416
|
+
return self._turn_base() / "stages" / "10_prompt" / "output"
|
|
417
|
+
if kind == "plan":
|
|
418
|
+
return self._turn_base() / "stages" / "30_plan" / "output"
|
|
419
|
+
if kind == "result":
|
|
420
|
+
return self._turn_base() / "stages" / "50_execution" / "output"
|
|
421
|
+
raise WorkspaceError(f"artifact_kind:{kind}")
|
|
422
|
+
|
|
423
|
+
def publish_artifact(self, kind: str, artifact_id: str, body: str, *, confirmed: bool, source_prompt_id: str | None = None, confirmed_prompt_hash: str | None = None) -> None:
|
|
424
|
+
output = self._artifact_stage(kind)
|
|
425
|
+
versions = output / "versions"
|
|
426
|
+
versions.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
self._write(versions / f"{artifact_id}.md", body.rstrip() + "\n")
|
|
428
|
+
self._write(output / "current.md", body.rstrip() + "\n")
|
|
429
|
+
payload: dict[str, Any] = {
|
|
430
|
+
"artifact_id": artifact_id,
|
|
431
|
+
"confirmed": confirmed,
|
|
432
|
+
"source_prompt_id": source_prompt_id,
|
|
433
|
+
}
|
|
434
|
+
if confirmed_prompt_hash:
|
|
435
|
+
payload["confirmed_prompt_hash"] = confirmed_prompt_hash
|
|
436
|
+
self._write(output / "current.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
437
|
+
self.append_event(
|
|
438
|
+
"ARTIFACT_PUBLISHED",
|
|
439
|
+
{"kind": kind, "artifact_id": artifact_id, "confirmed": confirmed},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def invalidate_artifact(self, kind: str, reason: str) -> None:
|
|
443
|
+
output = self._artifact_stage(kind)
|
|
444
|
+
meta_path = output / "current.json"
|
|
445
|
+
body_path = output / "current.md"
|
|
446
|
+
if not meta_path.is_file() or not body_path.is_file():
|
|
447
|
+
return
|
|
448
|
+
meta = json.loads(self._read(meta_path))
|
|
449
|
+
artifact_id = meta.get("artifact_id")
|
|
450
|
+
invalidated = output / "invalidated"
|
|
451
|
+
invalidated.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
self._write(
|
|
453
|
+
invalidated / f"{artifact_id}.json",
|
|
454
|
+
json.dumps({**meta, "status": "invalidated", "reason": reason}, ensure_ascii=False, indent=2) + "\n",
|
|
455
|
+
)
|
|
456
|
+
meta_path.unlink()
|
|
457
|
+
body_path.unlink()
|
|
458
|
+
self.append_event("ARTIFACT_INVALIDATED", {"kind": kind, "artifact_id": artifact_id, "reason": reason})
|
|
459
|
+
|
|
460
|
+
def mark_artifact_confirmed(self, kind: str, artifact_id: str) -> None:
|
|
461
|
+
output = self._artifact_stage(kind)
|
|
462
|
+
meta_path = output / "current.json"
|
|
463
|
+
meta = json.loads(self._read(meta_path))
|
|
464
|
+
if meta.get("artifact_id") != artifact_id:
|
|
465
|
+
raise WorkspaceError("artifact_confirmation_identity")
|
|
466
|
+
meta["confirmed"] = True
|
|
467
|
+
self._write(meta_path, json.dumps(meta, ensure_ascii=False, indent=2) + "\n")
|
|
468
|
+
self.append_event("ARTIFACT_CONFIRMED", {"kind": kind, "artifact_id": artifact_id})
|
|
469
|
+
|
|
470
|
+
def read_artifact(self, kind: str) -> tuple[dict[str, Any], str]:
|
|
471
|
+
output = self._artifact_stage(kind)
|
|
472
|
+
meta_path = output / "current.json"
|
|
473
|
+
body_path = output / "current.md"
|
|
474
|
+
if not meta_path.is_file() or not body_path.is_file():
|
|
475
|
+
raise WorkspaceError(f"artifact_missing:{kind}")
|
|
476
|
+
return (
|
|
477
|
+
json.loads(self._read(meta_path)),
|
|
478
|
+
self._read(body_path).rstrip("\n"),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def publish_approach_sources(self, sources: list[str]) -> None:
|
|
482
|
+
shared = self.path / "shared"
|
|
483
|
+
shared.mkdir(exist_ok=True)
|
|
484
|
+
self._write(
|
|
485
|
+
shared / "approach-sources.json",
|
|
486
|
+
json.dumps({"sources": sources}, ensure_ascii=False, indent=2) + "\n",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def read_approach_sources(self) -> list[str]:
|
|
490
|
+
path = self.path / "shared" / "approach-sources.json"
|
|
491
|
+
if not path.is_file():
|
|
492
|
+
return []
|
|
493
|
+
value = json.loads(self._read(path))
|
|
494
|
+
sources = value.get("sources", [])
|
|
495
|
+
if not isinstance(sources, list) or any(not isinstance(x, str) or not x.strip() for x in sources):
|
|
496
|
+
raise WorkspaceError("approach_source_file")
|
|
497
|
+
return sources
|
|
498
|
+
|
|
499
|
+
def publish_execution_outcome(self, kind: str, body: str, metadata: dict[str, Any] | None = None) -> None:
|
|
500
|
+
output = self._turn_base() / "stages" / "50_execution" / "output"
|
|
501
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
502
|
+
self._write(output / "current.md", body.rstrip() + "\n")
|
|
503
|
+
payload = {"kind": kind}
|
|
504
|
+
if metadata:
|
|
505
|
+
payload.update(metadata)
|
|
506
|
+
self._write(output / "current.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
507
|
+
self.append_event("EXECUTION_OUTCOME_PUBLISHED", payload)
|
|
508
|
+
|
|
509
|
+
def validate_confirmed_artifact(self, kind: str, artifact_id: str, body: str) -> None:
|
|
510
|
+
meta, workspace_body = self.read_artifact(kind)
|
|
511
|
+
if meta.get("artifact_id") != artifact_id or not meta.get("confirmed"):
|
|
512
|
+
raise WorkspaceError(f"confirmed_artifact_binding:{kind}")
|
|
513
|
+
if workspace_body != body:
|
|
514
|
+
raise WorkspaceError(f"confirmed_artifact_modified:{kind}")
|
|
515
|
+
|
|
516
|
+
def sync_unconfirmed_edit(self, kind: str, artifact_id: str, controller_body: str) -> str:
|
|
517
|
+
output = self._artifact_stage(kind)
|
|
518
|
+
meta, workspace_body = self.read_artifact(kind)
|
|
519
|
+
if meta.get("artifact_id") != artifact_id:
|
|
520
|
+
raise WorkspaceError(f"artifact_identity:{kind}")
|
|
521
|
+
if meta.get("confirmed"):
|
|
522
|
+
if workspace_body != controller_body:
|
|
523
|
+
raise WorkspaceError(f"confirmed_artifact_modified:{kind}")
|
|
524
|
+
return controller_body
|
|
525
|
+
if workspace_body != controller_body:
|
|
526
|
+
versions = output / "versions"
|
|
527
|
+
versions.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
self._write(versions / f"{artifact_id}.md", workspace_body.rstrip() + "\n")
|
|
529
|
+
meta["workspace_edited"] = True
|
|
530
|
+
self._write(output / "current.json", json.dumps(meta, ensure_ascii=False, indent=2) + "\n")
|
|
531
|
+
self.append_event(
|
|
532
|
+
"UNCONFIRMED_ARTIFACT_EDIT_DETECTED",
|
|
533
|
+
{"kind": kind, "artifact_id": artifact_id},
|
|
534
|
+
)
|
|
535
|
+
return workspace_body
|
|
536
|
+
return controller_body
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class MemoryWorkspaceRun(WorkspaceRun):
|
|
540
|
+
"""Software-defined In-Memory VFS WorkspaceRun (ADR-0011).
|
|
541
|
+
|
|
542
|
+
Eliminates blocking fsync and mkstemp latency on Windows NTFS during
|
|
543
|
+
active turn materialization by maintaining stage symbols in memory buffers
|
|
544
|
+
and performing fast, unjournaled writes to disk without synchronous fsync.
|
|
545
|
+
"""
|
|
546
|
+
|
|
547
|
+
def __init__(self, repo_root: Path, path: Path):
|
|
548
|
+
super().__init__(repo_root, path)
|
|
549
|
+
self._vfs: dict[Path, str] = {}
|
|
550
|
+
self._events: list[dict[str, Any]] = []
|
|
551
|
+
|
|
552
|
+
def _write(self, path: Path, content: str) -> None:
|
|
553
|
+
self._vfs[path] = content
|
|
554
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
555
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
556
|
+
|
|
557
|
+
def _read(self, path: Path) -> str:
|
|
558
|
+
if path in self._vfs:
|
|
559
|
+
return self._vfs[path]
|
|
560
|
+
return path.read_text(encoding="utf-8")
|
|
561
|
+
|
|
562
|
+
def _record_event(self, event: dict[str, Any]) -> None:
|
|
563
|
+
self._events.append(event)
|
|
564
|
+
events = self._turn_base() / "events" / "events.jsonl"
|
|
565
|
+
events.parent.mkdir(parents=True, exist_ok=True)
|
|
566
|
+
with events.open("a", encoding="utf-8", newline="\n") as handle:
|
|
567
|
+
handle.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
568
|
+
handle.flush()
|
|
569
|
+
# Bypasses blocking os.fsync(handle.fileno()) per ADR-0011 §1
|
|
570
|
+
|
|
571
|
+
def start_turn(self, new_turn_id: str) -> None:
|
|
572
|
+
super().start_turn(new_turn_id)
|
|
573
|
+
self._events = []
|
|
574
|
+
|
|
575
|
+
def mark_turn_status(self, status: str, *, deliverable_sha256: str | None = None) -> None:
|
|
576
|
+
super().mark_turn_status(status, deliverable_sha256=deliverable_sha256)
|
|
577
|
+
if status in {"CLOSED_SUCCESS", "CLOSED_CANCELLED"}:
|
|
578
|
+
self.flush_turn_archive()
|
|
579
|
+
|
|
580
|
+
def flush_turn_archive(self, turn_id: str | None = None) -> Path:
|
|
581
|
+
"""Atomic turn flush: persist turn state and events to a single archive record (ADR-0011 §2)."""
|
|
582
|
+
tid = turn_id or self.turn_id
|
|
583
|
+
if tid:
|
|
584
|
+
turn_dir = self.path / "turns" / tid
|
|
585
|
+
turn_meta = self.read_turn_status(tid)
|
|
586
|
+
status = turn_meta.get("status")
|
|
587
|
+
deliverable_sha256 = turn_meta.get("deliverable_sha256")
|
|
588
|
+
else:
|
|
589
|
+
turn_dir = self.path
|
|
590
|
+
status = self.metadata.get("status")
|
|
591
|
+
deliverable_sha256 = self.metadata.get("deliverable_sha256")
|
|
592
|
+
archive_path = turn_dir / "turn_archive.json"
|
|
593
|
+
archive_data = {
|
|
594
|
+
"turn_id": tid,
|
|
595
|
+
"status": status,
|
|
596
|
+
"deliverable_sha256": deliverable_sha256,
|
|
597
|
+
"events": list(self._events),
|
|
598
|
+
"flushed_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
599
|
+
}
|
|
600
|
+
archive_path.write_text(json.dumps(archive_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
601
|
+
return archive_path
|
|
602
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tracking-only MLflow tooling."""
|