claude-dev-env 8.26.6 → 8.28.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/.agents/skills/orchestrator/SKILL.md +125 -329
- package/.agents/skills/orchestrator/reference/consult-the-orchestrator.md +29 -57
- package/.agents/skills/orchestrator/reference/executor-consult-block.md +26 -57
- package/.agents/skills/orchestrator/reference/host-detect.md +14 -11
- package/.agents/skills/orchestrator/reference/platform-evidence.md +49 -0
- package/.agents/skills/orchestrator/reference/recovery.md +95 -0
- package/.agents/skills/orchestrator/reference/run-state.md +113 -0
- package/.agents/skills/orchestrator/reference/scheduling.md +67 -0
- package/.agents/skills/orchestrator/scripts/status_gate.py +59 -27
- package/.agents/skills/orchestrator/scripts/status_gate_constants/config/constants.py +1 -0
- package/.agents/skills/orchestrator/scripts/test_status_gate.py +165 -0
- package/.agents/skills/orchestrator-refresh/SKILL.md +43 -114
- package/bin/export-pstack-skills.mjs +71 -0
- package/bin/export-pstack-skills.test.mjs +64 -0
- package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +4 -3
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +14 -0
- package/scripts/grok_run_ledger.py +96 -3
- package/scripts/test_grok_run_ledger.py +373 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { exportPstackSkills } from './export-pstack-skills.mjs';
|
|
8
|
+
|
|
9
|
+
function fixture(context) {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'pstack-export-test-'));
|
|
11
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
12
|
+
const plugin = join(root, 'plugin');
|
|
13
|
+
const target = join(root, 'repo', '.agents', 'skills');
|
|
14
|
+
mkdirSync(join(plugin, '.codex-plugin'), { recursive: true });
|
|
15
|
+
writeFileSync(join(plugin, '.codex-plugin', 'plugin.json'), JSON.stringify({
|
|
16
|
+
name: 'pstack', version: '0.9.53', repository: 'https://github.com/michael-denyer/pstack-claude', skills: './skills/',
|
|
17
|
+
}));
|
|
18
|
+
mkdirSync(join(plugin, 'skills', 'poteto-mode', 'references'), { recursive: true });
|
|
19
|
+
mkdirSync(join(plugin, 'skills', 'unslop'), { recursive: true });
|
|
20
|
+
writeFileSync(join(plugin, 'skills', 'poteto-mode', 'SKILL.md'), '---\nname: poteto-mode\n---\nUpstream body\n');
|
|
21
|
+
writeFileSync(join(plugin, 'skills', 'poteto-mode', 'references', 'codex-tools.md'), 'Tool mapping\n');
|
|
22
|
+
writeFileSync(join(plugin, 'skills', 'unslop', 'SKILL.md'), 'Sibling dependency\n');
|
|
23
|
+
mkdirSync(join(plugin, 'skills', 'poteto-mode', 'references', 'licenses'));
|
|
24
|
+
writeFileSync(join(plugin, 'skills', 'poteto-mode', 'references', 'licenses', 'LICENSE'), 'Upstream license\n');
|
|
25
|
+
return { root, plugin, target };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('exports upstream bodies and dependencies unchanged with verifiable provenance', context => {
|
|
29
|
+
const { plugin, target } = fixture(context);
|
|
30
|
+
const receipt = exportPstackSkills(plugin, target);
|
|
31
|
+
assert.equal(receipt.skillCount, 2);
|
|
32
|
+
assert.equal(receipt.cloudInvocation, 'unverified');
|
|
33
|
+
assert.equal(receipt.expectedRepositorySkillName, 'poteto-mode');
|
|
34
|
+
for (const [path, hash] of Object.entries(receipt.files)) {
|
|
35
|
+
const exported = readFileSync(join(target, path));
|
|
36
|
+
assert.deepEqual(exported, readFileSync(join(plugin, 'skills', path)));
|
|
37
|
+
assert.equal(createHash('sha256').update(exported).digest('hex'), hash);
|
|
38
|
+
}
|
|
39
|
+
assert.deepEqual(JSON.parse(readFileSync(join(target, 'pstack-export.json'))), receipt);
|
|
40
|
+
assert.equal(readFileSync(join(target, 'PSTACK-LICENSE'), 'utf8'), 'Upstream license\n');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('existing custom skills survive a rejected repeat export', context => {
|
|
44
|
+
const { plugin, target } = fixture(context);
|
|
45
|
+
exportPstackSkills(plugin, target);
|
|
46
|
+
const customPath = join(target, 'poteto-mode', 'SKILL.md');
|
|
47
|
+
writeFileSync(customPath, 'Custom instructions\n');
|
|
48
|
+
assert.throws(() => exportPstackSkills(plugin, target), /already exists/);
|
|
49
|
+
assert.equal(readFileSync(customPath, 'utf8'), 'Custom instructions\n');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a source symlink is rejected before creating the destination', context => {
|
|
53
|
+
const { plugin, target } = fixture(context);
|
|
54
|
+
symlinkSync(join(plugin, 'skills', 'poteto-mode', 'SKILL.md'), join(plugin, 'skills', 'linked-skill'));
|
|
55
|
+
assert.throws(() => exportPstackSkills(plugin, target), /Unsupported source entry/);
|
|
56
|
+
assert.equal(existsSync(target), false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a plugin without the required poteto entry cannot produce a partial export', context => {
|
|
60
|
+
const { plugin, target } = fixture(context);
|
|
61
|
+
rmSync(join(plugin, 'skills', 'poteto-mode', 'SKILL.md'));
|
|
62
|
+
assert.throws(() => exportPstackSkills(plugin, target), /ENOENT/);
|
|
63
|
+
assert.equal(existsSync(target), false);
|
|
64
|
+
});
|
|
@@ -16,7 +16,8 @@ ALL_ORCHESTRATOR_STARTER_ENABLED_ENV_VALUES: frozenset[str] = frozenset(
|
|
|
16
16
|
ORCHESTRATOR_STARTER_TIMEOUT_MILLISECONDS: int = 50
|
|
17
17
|
|
|
18
18
|
ORCHESTRATOR_SESSION_START_DIRECTIVE: str = (
|
|
19
|
-
"SessionStart orchestrator opt-in is active. Load the orchestrator skill "
|
|
20
|
-
"
|
|
21
|
-
"
|
|
19
|
+
"SessionStart orchestrator opt-in is active. Load the orchestrator skill. "
|
|
20
|
+
"Track this session's goals across inline and delegated work. "
|
|
21
|
+
"Recover saved state at startup, resume, and after compaction. "
|
|
22
|
+
"Keep context small by delegating bulky work when useful."
|
|
22
23
|
)
|
package/package.json
CHANGED
|
@@ -20,6 +20,9 @@ TASK_STATUS_IN_PROGRESS: str = "in_progress"
|
|
|
20
20
|
TASK_STATUS_COMPLETED: str = "completed"
|
|
21
21
|
"""Task reached a successful terminal state."""
|
|
22
22
|
|
|
23
|
+
TASK_STATUS_CANCELLED: str = "cancelled"
|
|
24
|
+
"""Task stopped after caller-authorized cancellation and owner termination."""
|
|
25
|
+
|
|
23
26
|
TASK_STATUS_ADVISOR_BLOCKED: str = "advisor_blocked"
|
|
24
27
|
"""Task stopped because the advisor bind or verdict failed closed."""
|
|
25
28
|
|
|
@@ -31,12 +34,23 @@ ALL_LEGAL_TASK_STATUSES: frozenset[str] = frozenset(
|
|
|
31
34
|
TASK_STATUS_PENDING,
|
|
32
35
|
TASK_STATUS_IN_PROGRESS,
|
|
33
36
|
TASK_STATUS_COMPLETED,
|
|
37
|
+
TASK_STATUS_CANCELLED,
|
|
34
38
|
TASK_STATUS_ADVISOR_BLOCKED,
|
|
35
39
|
TASK_STATUS_PENDING_REVIEW,
|
|
36
40
|
}
|
|
37
41
|
)
|
|
38
42
|
"""Every legal task status the ledger accepts."""
|
|
39
43
|
|
|
44
|
+
ALL_CANCELLABLE_TASK_STATUSES: frozenset[str] = frozenset(
|
|
45
|
+
{
|
|
46
|
+
TASK_STATUS_PENDING,
|
|
47
|
+
TASK_STATUS_IN_PROGRESS,
|
|
48
|
+
TASK_STATUS_ADVISOR_BLOCKED,
|
|
49
|
+
TASK_STATUS_PENDING_REVIEW,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
"""Task states that permit terminal cancellation."""
|
|
53
|
+
|
|
40
54
|
UTF8_ENCODING: str = "utf-8"
|
|
41
55
|
"""Text encoding for ledger and patch-manifest files."""
|
|
42
56
|
|
|
@@ -27,11 +27,13 @@ from dataclasses import asdict, dataclass, field
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
29
|
from dev_env_scripts_constants.grok_run_ledger_constants import (
|
|
30
|
+
ALL_CANCELLABLE_TASK_STATUSES,
|
|
30
31
|
ALL_LEGAL_TASK_STATUSES,
|
|
31
32
|
JSON_INDENT,
|
|
32
33
|
LEDGER_FILENAME,
|
|
33
34
|
LEDGER_SCHEMA_VERSION,
|
|
34
35
|
TASK_STATUS_ADVISOR_BLOCKED,
|
|
36
|
+
TASK_STATUS_CANCELLED,
|
|
35
37
|
TASK_STATUS_COMPLETED,
|
|
36
38
|
TASK_STATUS_IN_PROGRESS,
|
|
37
39
|
TASK_STATUS_PENDING,
|
|
@@ -41,7 +43,6 @@ from dev_env_scripts_constants.grok_run_ledger_constants import (
|
|
|
41
43
|
UTF8_ENCODING,
|
|
42
44
|
)
|
|
43
45
|
|
|
44
|
-
|
|
45
46
|
@dataclass
|
|
46
47
|
class LedgerTaskRecord:
|
|
47
48
|
"""One delegated unit tracked in the run ledger."""
|
|
@@ -163,15 +164,17 @@ class GrokRunLedger:
|
|
|
163
164
|
return self._task_by_id[task_id]
|
|
164
165
|
|
|
165
166
|
def can_dispatch(self, task_id: str) -> bool:
|
|
166
|
-
"""Return whether
|
|
167
|
+
"""Return whether an uncancelled task has completed dependencies.
|
|
167
168
|
|
|
168
169
|
Args:
|
|
169
170
|
task_id: Task to evaluate.
|
|
170
171
|
|
|
171
172
|
Returns:
|
|
172
|
-
|
|
173
|
+
Whether the task is uncancelled and every dependency is completed.
|
|
173
174
|
"""
|
|
174
175
|
record = self.get_task(task_id)
|
|
176
|
+
if record.status == TASK_STATUS_CANCELLED:
|
|
177
|
+
return False
|
|
175
178
|
for each_dependency in record.dependencies:
|
|
176
179
|
dependency_record = self._task_by_id.get(each_dependency)
|
|
177
180
|
if dependency_record is None:
|
|
@@ -229,6 +232,92 @@ class GrokRunLedger:
|
|
|
229
232
|
self._atomic_write()
|
|
230
233
|
return record
|
|
231
234
|
|
|
235
|
+
def release_terminated_owner(
|
|
236
|
+
self,
|
|
237
|
+
*,
|
|
238
|
+
task_id: str,
|
|
239
|
+
expected_owner_id: str,
|
|
240
|
+
expected_advisor_session_id: str,
|
|
241
|
+
) -> LedgerTaskRecord:
|
|
242
|
+
"""Release after caller verifies death and saves assignment/artifact history.
|
|
243
|
+
Args:
|
|
244
|
+
task_id: Task to release under freshly loaded single-writer ownership.
|
|
245
|
+
expected_owner_id: Nonempty owner identity from the assignment.
|
|
246
|
+
expected_advisor_session_id: Nonempty session from the assignment.
|
|
247
|
+
Returns:
|
|
248
|
+
Pending-review task retaining its base, session, and evidence.
|
|
249
|
+
Raises:
|
|
250
|
+
ValueError: When status or either current identity does not match.
|
|
251
|
+
"""
|
|
252
|
+
record = self.get_task(task_id)
|
|
253
|
+
if record.status != TASK_STATUS_IN_PROGRESS:
|
|
254
|
+
raise ValueError(f"cannot release task from status {record.status}")
|
|
255
|
+
if not expected_owner_id or record.owner_id != expected_owner_id:
|
|
256
|
+
raise ValueError(f"owner identity changed for {task_id}")
|
|
257
|
+
session_matches = record.advisor_session_id == expected_advisor_session_id
|
|
258
|
+
if not expected_advisor_session_id or not session_matches:
|
|
259
|
+
raise ValueError(f"advisor session changed for {task_id}")
|
|
260
|
+
record.status = TASK_STATUS_PENDING_REVIEW
|
|
261
|
+
record.owner_id = None
|
|
262
|
+
self._atomic_write()
|
|
263
|
+
return record
|
|
264
|
+
|
|
265
|
+
def mark_cancelled(
|
|
266
|
+
self,
|
|
267
|
+
*,
|
|
268
|
+
task_id: str,
|
|
269
|
+
expected_owner_id: str | None,
|
|
270
|
+
expected_advisor_session_id: str | None,
|
|
271
|
+
reason: str,
|
|
272
|
+
) -> LedgerTaskRecord:
|
|
273
|
+
"""Cancel after the caller saves authorization and confirms owner termination.
|
|
274
|
+
Args:
|
|
275
|
+
task_id: Task under freshly loaded single-writer ownership.
|
|
276
|
+
expected_owner_id: Current owner, or None for an unassigned task.
|
|
277
|
+
expected_advisor_session_id: Current advisor session, or None.
|
|
278
|
+
reason: Nonempty cancellation evidence to append.
|
|
279
|
+
Returns:
|
|
280
|
+
Cancelled record retaining evidence and session; retries do not write.
|
|
281
|
+
"""
|
|
282
|
+
record = self.get_task(task_id)
|
|
283
|
+
self._require_cancellation_request(
|
|
284
|
+
record, expected_owner_id, expected_advisor_session_id, reason
|
|
285
|
+
)
|
|
286
|
+
if record.status == TASK_STATUS_CANCELLED:
|
|
287
|
+
return record
|
|
288
|
+
record.status = TASK_STATUS_CANCELLED
|
|
289
|
+
record.owner_id = None
|
|
290
|
+
record.test_evidence.append(reason)
|
|
291
|
+
self._atomic_write()
|
|
292
|
+
return record
|
|
293
|
+
|
|
294
|
+
def _require_cancellation_request(
|
|
295
|
+
self,
|
|
296
|
+
record: LedgerTaskRecord,
|
|
297
|
+
expected_owner_id: str | None,
|
|
298
|
+
expected_advisor_session_id: str | None,
|
|
299
|
+
reason: str,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Compare both identities before changing cancellation state."""
|
|
302
|
+
if expected_owner_id == "" or record.owner_id != expected_owner_id:
|
|
303
|
+
raise ValueError(f"owner identity changed for {record.task_id}")
|
|
304
|
+
if (
|
|
305
|
+
expected_advisor_session_id == ""
|
|
306
|
+
or record.advisor_session_id != expected_advisor_session_id
|
|
307
|
+
):
|
|
308
|
+
raise ValueError(f"advisor session changed for {record.task_id}")
|
|
309
|
+
if record.status == TASK_STATUS_IN_PROGRESS and (
|
|
310
|
+
expected_owner_id is None or expected_advisor_session_id is None
|
|
311
|
+
):
|
|
312
|
+
raise ValueError("in-progress cancellation requires owner and session")
|
|
313
|
+
if not reason.strip():
|
|
314
|
+
raise ValueError("cancellation reason must be nonempty")
|
|
315
|
+
if (
|
|
316
|
+
record.status != TASK_STATUS_CANCELLED
|
|
317
|
+
and record.status not in ALL_CANCELLABLE_TASK_STATUSES
|
|
318
|
+
):
|
|
319
|
+
raise ValueError(f"cannot cancel task from status {record.status}")
|
|
320
|
+
|
|
232
321
|
def mark_completed(
|
|
233
322
|
self,
|
|
234
323
|
*,
|
|
@@ -278,6 +367,8 @@ class GrokRunLedger:
|
|
|
278
367
|
The blocked task record.
|
|
279
368
|
"""
|
|
280
369
|
record = self.get_task(task_id)
|
|
370
|
+
if record.status == TASK_STATUS_CANCELLED:
|
|
371
|
+
raise ValueError(f"cannot block task from status {record.status}")
|
|
281
372
|
record.status = TASK_STATUS_ADVISOR_BLOCKED
|
|
282
373
|
record.test_evidence = [reason]
|
|
283
374
|
self._atomic_write()
|
|
@@ -299,6 +390,8 @@ class GrokRunLedger:
|
|
|
299
390
|
The updated task record (unchanged when SHAs match).
|
|
300
391
|
"""
|
|
301
392
|
record = self.get_task(task_id)
|
|
393
|
+
if record.status == TASK_STATUS_CANCELLED:
|
|
394
|
+
return record
|
|
302
395
|
if record.base_sha is None or record.base_sha == current_sha:
|
|
303
396
|
return record
|
|
304
397
|
record.status = TASK_STATUS_PENDING_REVIEW
|
|
@@ -114,3 +114,376 @@ def test_completed_records_acceptance_and_head(tmp_path: Path) -> None:
|
|
|
114
114
|
assert record.status == TASK_STATUS_COMPLETED
|
|
115
115
|
assert record.reviewed_head == "head"
|
|
116
116
|
assert record.changed_paths == ("a.py",)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_release_terminated_owner_allows_same_base_reassignment_after_reload(
|
|
120
|
+
tmp_path: Path,
|
|
121
|
+
) -> None:
|
|
122
|
+
ledger = GrokRunLedger(tmp_path)
|
|
123
|
+
ledger.register_task(task_id="task", all_dependencies=())
|
|
124
|
+
ledger.mark_in_progress(
|
|
125
|
+
task_id="task",
|
|
126
|
+
owner_id="old-owner",
|
|
127
|
+
advisor_session_id="old-session",
|
|
128
|
+
base_sha="base",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
released_record = ledger.release_terminated_owner(
|
|
132
|
+
task_id="task",
|
|
133
|
+
expected_owner_id="old-owner",
|
|
134
|
+
expected_advisor_session_id="old-session",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
assert released_record.status == TASK_STATUS_PENDING_REVIEW
|
|
138
|
+
assert released_record.owner_id is None
|
|
139
|
+
assert released_record.advisor_session_id == "old-session"
|
|
140
|
+
assert released_record.base_sha == "base"
|
|
141
|
+
|
|
142
|
+
reloaded_ledger = GrokRunLedger(tmp_path)
|
|
143
|
+
replacement_record = reloaded_ledger.mark_in_progress(
|
|
144
|
+
task_id="task",
|
|
145
|
+
owner_id="new-owner",
|
|
146
|
+
advisor_session_id="new-session",
|
|
147
|
+
base_sha="base",
|
|
148
|
+
)
|
|
149
|
+
persisted_record = GrokRunLedger(tmp_path).get_task("task")
|
|
150
|
+
|
|
151
|
+
assert replacement_record.base_sha == "base"
|
|
152
|
+
assert persisted_record.status == "in_progress"
|
|
153
|
+
assert persisted_record.owner_id == "new-owner"
|
|
154
|
+
assert persisted_record.advisor_session_id == "new-session"
|
|
155
|
+
assert persisted_record.base_sha == "base"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.parametrize(
|
|
159
|
+
(
|
|
160
|
+
"expected_owner_id",
|
|
161
|
+
"expected_advisor_session_id",
|
|
162
|
+
"persisted_owner_id",
|
|
163
|
+
"persisted_advisor_session_id",
|
|
164
|
+
"should_complete",
|
|
165
|
+
),
|
|
166
|
+
[
|
|
167
|
+
("different-owner", "session", "owner", "session", False),
|
|
168
|
+
("owner", "different-session", "owner", "session", False),
|
|
169
|
+
("", "session", "", "session", False),
|
|
170
|
+
("owner", "", "owner", "", False),
|
|
171
|
+
("owner", "session", "owner", "session", True),
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
def test_release_terminated_owner_rejects_stale_identity_and_illegal_status(
|
|
175
|
+
tmp_path: Path,
|
|
176
|
+
expected_owner_id: str,
|
|
177
|
+
expected_advisor_session_id: str,
|
|
178
|
+
persisted_owner_id: str,
|
|
179
|
+
persisted_advisor_session_id: str,
|
|
180
|
+
should_complete: bool,
|
|
181
|
+
) -> None:
|
|
182
|
+
ledger = GrokRunLedger(tmp_path)
|
|
183
|
+
ledger.register_task(task_id="task", all_dependencies=())
|
|
184
|
+
ledger.mark_in_progress(
|
|
185
|
+
task_id="task",
|
|
186
|
+
owner_id=persisted_owner_id,
|
|
187
|
+
advisor_session_id=persisted_advisor_session_id,
|
|
188
|
+
base_sha="base",
|
|
189
|
+
)
|
|
190
|
+
ledger_path = tmp_path / "grok-run-ledger.json"
|
|
191
|
+
if should_complete:
|
|
192
|
+
ledger.mark_completed(
|
|
193
|
+
task_id="task",
|
|
194
|
+
reviewed_head="head",
|
|
195
|
+
all_changed_paths=(),
|
|
196
|
+
advisor_verdict="ENDORSE",
|
|
197
|
+
all_acceptance_mapping={},
|
|
198
|
+
all_test_evidence=["pytest -q"],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
task_before = ledger.get_task("task")
|
|
202
|
+
task_state_before = (
|
|
203
|
+
task_before.status,
|
|
204
|
+
task_before.owner_id,
|
|
205
|
+
task_before.advisor_session_id,
|
|
206
|
+
task_before.base_sha,
|
|
207
|
+
)
|
|
208
|
+
persisted_bytes_before = ledger_path.read_bytes()
|
|
209
|
+
|
|
210
|
+
with pytest.raises(ValueError):
|
|
211
|
+
ledger.release_terminated_owner(
|
|
212
|
+
task_id="task",
|
|
213
|
+
expected_owner_id=expected_owner_id,
|
|
214
|
+
expected_advisor_session_id=expected_advisor_session_id,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
task_after = ledger.get_task("task")
|
|
218
|
+
assert ledger_path.read_bytes() == persisted_bytes_before
|
|
219
|
+
assert (
|
|
220
|
+
task_after.status,
|
|
221
|
+
task_after.owner_id,
|
|
222
|
+
task_after.advisor_session_id,
|
|
223
|
+
task_after.base_sha,
|
|
224
|
+
) == task_state_before
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_release_terminated_owner_preserves_dependencies_and_evidence(
|
|
228
|
+
tmp_path: Path,
|
|
229
|
+
) -> None:
|
|
230
|
+
ledger = GrokRunLedger(tmp_path)
|
|
231
|
+
ledger.register_task(task_id="dependency", all_dependencies=())
|
|
232
|
+
ledger.mark_in_progress(
|
|
233
|
+
task_id="dependency",
|
|
234
|
+
owner_id="dependency-owner",
|
|
235
|
+
advisor_session_id="dependency-session",
|
|
236
|
+
base_sha="base",
|
|
237
|
+
)
|
|
238
|
+
ledger.mark_completed(
|
|
239
|
+
task_id="dependency",
|
|
240
|
+
reviewed_head="dependency-head",
|
|
241
|
+
all_changed_paths=(),
|
|
242
|
+
advisor_verdict="ENDORSE",
|
|
243
|
+
all_acceptance_mapping={},
|
|
244
|
+
all_test_evidence=["dependency passed"],
|
|
245
|
+
)
|
|
246
|
+
ledger.register_task(task_id="task", all_dependencies=("dependency",))
|
|
247
|
+
ledger.mark_in_progress(
|
|
248
|
+
task_id="task",
|
|
249
|
+
owner_id="owner",
|
|
250
|
+
advisor_session_id="session",
|
|
251
|
+
base_sha="base",
|
|
252
|
+
)
|
|
253
|
+
ledger_path = tmp_path / "grok-run-ledger.json"
|
|
254
|
+
ledger_document = json.loads(ledger_path.read_text(encoding="utf-8"))
|
|
255
|
+
target_task = next(
|
|
256
|
+
each_task
|
|
257
|
+
for each_task in ledger_document["tasks"]
|
|
258
|
+
if each_task["task_id"] == "task"
|
|
259
|
+
)
|
|
260
|
+
target_task["advisor_verdict"] = "ENDORSE"
|
|
261
|
+
target_task["reviewed_head"] = "reviewed-head"
|
|
262
|
+
target_task["changed_paths"] = ["source.py"]
|
|
263
|
+
target_task["acceptance_mapping"] = {"criterion": "evidence"}
|
|
264
|
+
target_task["test_evidence"] = ["pytest -q"]
|
|
265
|
+
ledger_path.write_text(
|
|
266
|
+
json.dumps(ledger_document, indent=2) + "\n",
|
|
267
|
+
encoding="utf-8",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
reloaded_ledger = GrokRunLedger(tmp_path)
|
|
271
|
+
reloaded_ledger.release_terminated_owner(
|
|
272
|
+
task_id="task",
|
|
273
|
+
expected_owner_id="owner",
|
|
274
|
+
expected_advisor_session_id="session",
|
|
275
|
+
)
|
|
276
|
+
persisted_record = GrokRunLedger(tmp_path).get_task("task")
|
|
277
|
+
|
|
278
|
+
assert persisted_record.dependencies == ("dependency",)
|
|
279
|
+
assert persisted_record.advisor_session_id == "session"
|
|
280
|
+
assert persisted_record.base_sha == "base"
|
|
281
|
+
assert persisted_record.advisor_verdict == "ENDORSE"
|
|
282
|
+
assert persisted_record.reviewed_head == "reviewed-head"
|
|
283
|
+
assert persisted_record.changed_paths == ("source.py",)
|
|
284
|
+
assert persisted_record.acceptance_mapping == {"criterion": "evidence"}
|
|
285
|
+
assert persisted_record.test_evidence == ["pytest -q"]
|
|
286
|
+
assert reloaded_ledger.can_dispatch("task") is True
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _seed_cancellation_ledger(tmp_path: Path, status: str) -> GrokRunLedger:
|
|
290
|
+
ledger = GrokRunLedger(tmp_path)
|
|
291
|
+
ledger.register_task(task_id="task", all_dependencies=("dependency",))
|
|
292
|
+
ledger_path = tmp_path / "grok-run-ledger.json"
|
|
293
|
+
ledger_document = json.loads(ledger_path.read_text(encoding="utf-8"))
|
|
294
|
+
ledger_document["tasks"][0].update(
|
|
295
|
+
status=status,
|
|
296
|
+
owner_id="owner",
|
|
297
|
+
advisor_session_id="session",
|
|
298
|
+
base_sha="base",
|
|
299
|
+
reviewed_head="head",
|
|
300
|
+
changed_paths=["source.py"],
|
|
301
|
+
advisor_verdict="ENDORSE",
|
|
302
|
+
acceptance_mapping={"criterion": "proof"},
|
|
303
|
+
test_evidence=["pytest passed"],
|
|
304
|
+
)
|
|
305
|
+
ledger_path.write_text(json.dumps(ledger_document), encoding="utf-8")
|
|
306
|
+
return GrokRunLedger(tmp_path)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@pytest.mark.parametrize(
|
|
310
|
+
"status", ["pending", "pending_review", "advisor_blocked", "in_progress"]
|
|
311
|
+
)
|
|
312
|
+
def test_mark_cancelled_preserves_assignment_and_evidence(
|
|
313
|
+
tmp_path: Path, status: str
|
|
314
|
+
) -> None:
|
|
315
|
+
ledger = _seed_cancellation_ledger(tmp_path, status)
|
|
316
|
+
previous_document = json.loads(ledger.ledger_path.read_text(encoding="utf-8"))
|
|
317
|
+
ledger.mark_cancelled(
|
|
318
|
+
task_id="task",
|
|
319
|
+
expected_owner_id="owner",
|
|
320
|
+
expected_advisor_session_id="session",
|
|
321
|
+
reason="User cancelled; owner stopped",
|
|
322
|
+
)
|
|
323
|
+
expected_document = previous_document
|
|
324
|
+
expected_document["tasks"][0].update(
|
|
325
|
+
status="cancelled",
|
|
326
|
+
owner_id=None,
|
|
327
|
+
test_evidence=["pytest passed", "User cancelled; owner stopped"],
|
|
328
|
+
)
|
|
329
|
+
assert (
|
|
330
|
+
json.loads(ledger.ledger_path.read_text(encoding="utf-8")) == expected_document
|
|
331
|
+
)
|
|
332
|
+
reloaded = GrokRunLedger(tmp_path)
|
|
333
|
+
assert reloaded.get_task("task").status == "cancelled"
|
|
334
|
+
assert is_legal_status(reloaded.get_task("task").status)
|
|
335
|
+
assert reloaded.can_dispatch("task") is False
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@pytest.mark.parametrize(
|
|
339
|
+
"expected_owner_id,expected_session,reason",
|
|
340
|
+
[
|
|
341
|
+
("other", "session", "cancel"),
|
|
342
|
+
("owner", "other", "cancel"),
|
|
343
|
+
("", "session", "cancel"),
|
|
344
|
+
("owner", "", "cancel"),
|
|
345
|
+
(None, "session", "cancel"),
|
|
346
|
+
("owner", None, "cancel"),
|
|
347
|
+
("owner", "session", ""),
|
|
348
|
+
("owner", "session", " "),
|
|
349
|
+
],
|
|
350
|
+
)
|
|
351
|
+
def test_mark_cancelled_rejects_invalid_request_without_changes(
|
|
352
|
+
tmp_path: Path,
|
|
353
|
+
expected_owner_id: str | None,
|
|
354
|
+
expected_session: str | None,
|
|
355
|
+
reason: str,
|
|
356
|
+
) -> None:
|
|
357
|
+
ledger = _seed_cancellation_ledger(tmp_path, "in_progress")
|
|
358
|
+
previous_bytes = ledger.ledger_path.read_bytes()
|
|
359
|
+
with pytest.raises(ValueError):
|
|
360
|
+
ledger.mark_cancelled(
|
|
361
|
+
task_id="task",
|
|
362
|
+
expected_owner_id=expected_owner_id,
|
|
363
|
+
expected_advisor_session_id=expected_session,
|
|
364
|
+
reason=reason,
|
|
365
|
+
)
|
|
366
|
+
assert ledger.ledger_path.read_bytes() == previous_bytes
|
|
367
|
+
assert ledger.get_task("task").status == "in_progress"
|
|
368
|
+
assert ledger.get_task("task").owner_id == "owner"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_mark_cancelled_rejects_completed_task(tmp_path: Path) -> None:
|
|
372
|
+
ledger = _seed_cancellation_ledger(tmp_path, "completed")
|
|
373
|
+
previous_bytes = ledger.ledger_path.read_bytes()
|
|
374
|
+
with pytest.raises(ValueError):
|
|
375
|
+
ledger.mark_cancelled(
|
|
376
|
+
task_id="task",
|
|
377
|
+
expected_owner_id="owner",
|
|
378
|
+
expected_advisor_session_id="session",
|
|
379
|
+
reason="cancel",
|
|
380
|
+
)
|
|
381
|
+
assert ledger.ledger_path.read_bytes() == previous_bytes
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@pytest.mark.parametrize(
|
|
385
|
+
"owner_id,session_id",
|
|
386
|
+
[(None, None), (None, "session"), ("owner", None), ("", "session"), ("owner", "")],
|
|
387
|
+
)
|
|
388
|
+
def test_mark_cancelled_rejects_incomplete_live_assignment(
|
|
389
|
+
tmp_path: Path,
|
|
390
|
+
owner_id: str | None,
|
|
391
|
+
session_id: str | None,
|
|
392
|
+
) -> None:
|
|
393
|
+
ledger = _seed_cancellation_ledger(tmp_path, "in_progress")
|
|
394
|
+
ledger_document = json.loads(ledger.ledger_path.read_text(encoding="utf-8"))
|
|
395
|
+
ledger_document["tasks"][0].update(owner_id=owner_id, advisor_session_id=session_id)
|
|
396
|
+
ledger.ledger_path.write_text(json.dumps(ledger_document), encoding="utf-8")
|
|
397
|
+
previous_bytes = ledger.ledger_path.read_bytes()
|
|
398
|
+
reloaded = GrokRunLedger(tmp_path)
|
|
399
|
+
with pytest.raises(ValueError):
|
|
400
|
+
reloaded.mark_cancelled(
|
|
401
|
+
task_id="task",
|
|
402
|
+
expected_owner_id=owner_id,
|
|
403
|
+
expected_advisor_session_id=session_id,
|
|
404
|
+
reason="cancel",
|
|
405
|
+
)
|
|
406
|
+
assert ledger.ledger_path.read_bytes() == previous_bytes
|
|
407
|
+
assert reloaded.get_task("task").status == "in_progress"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_mark_cancelled_accepts_unassigned_task_and_blocks_dependents(
|
|
411
|
+
tmp_path: Path,
|
|
412
|
+
) -> None:
|
|
413
|
+
ledger = GrokRunLedger(tmp_path)
|
|
414
|
+
ledger.register_task(task_id="task")
|
|
415
|
+
ledger.register_task(task_id="child", all_dependencies=("task",))
|
|
416
|
+
ledger.mark_cancelled(
|
|
417
|
+
task_id="task",
|
|
418
|
+
expected_owner_id=None,
|
|
419
|
+
expected_advisor_session_id=None,
|
|
420
|
+
reason="User cancelled before dispatch",
|
|
421
|
+
)
|
|
422
|
+
assert ledger.get_task("task").status == "cancelled"
|
|
423
|
+
assert ledger.get_task("task").owner_id is None
|
|
424
|
+
assert ledger.can_dispatch("task") is False
|
|
425
|
+
assert ledger.can_dispatch("child") is False
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_mark_cancelled_retry_requires_current_identity_and_leaves_bytes_unchanged(
|
|
429
|
+
tmp_path: Path,
|
|
430
|
+
) -> None:
|
|
431
|
+
ledger = _seed_cancellation_ledger(tmp_path, "in_progress")
|
|
432
|
+
ledger.mark_cancelled(
|
|
433
|
+
task_id="task",
|
|
434
|
+
expected_owner_id="owner",
|
|
435
|
+
expected_advisor_session_id="session",
|
|
436
|
+
reason="User cancelled; owner stopped",
|
|
437
|
+
)
|
|
438
|
+
previous_bytes = ledger.ledger_path.read_bytes()
|
|
439
|
+
previous_modified = ledger.ledger_path.stat().st_mtime_ns
|
|
440
|
+
reloaded = GrokRunLedger(tmp_path)
|
|
441
|
+
with pytest.raises(ValueError):
|
|
442
|
+
reloaded.mark_cancelled(
|
|
443
|
+
task_id="task",
|
|
444
|
+
expected_owner_id="owner",
|
|
445
|
+
expected_advisor_session_id="session",
|
|
446
|
+
reason="retry",
|
|
447
|
+
)
|
|
448
|
+
reloaded.mark_cancelled(
|
|
449
|
+
task_id="task",
|
|
450
|
+
expected_owner_id=None,
|
|
451
|
+
expected_advisor_session_id="session",
|
|
452
|
+
reason="retry after readback",
|
|
453
|
+
)
|
|
454
|
+
assert ledger.ledger_path.read_bytes() == previous_bytes
|
|
455
|
+
assert ledger.ledger_path.stat().st_mtime_ns == previous_modified
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_cancelled_task_stays_terminal_across_other_mutations(tmp_path: Path) -> None:
|
|
459
|
+
ledger = _seed_cancellation_ledger(tmp_path, "in_progress")
|
|
460
|
+
ledger.mark_cancelled(
|
|
461
|
+
task_id="task",
|
|
462
|
+
expected_owner_id="owner",
|
|
463
|
+
expected_advisor_session_id="session",
|
|
464
|
+
reason="User cancelled; owner stopped",
|
|
465
|
+
)
|
|
466
|
+
previous_bytes = ledger.ledger_path.read_bytes()
|
|
467
|
+
assert (
|
|
468
|
+
ledger.invalidate_on_snapshot_drift(task_id="task", current_sha="new").status
|
|
469
|
+
== "cancelled"
|
|
470
|
+
)
|
|
471
|
+
with pytest.raises(ValueError):
|
|
472
|
+
ledger.mark_advisor_blocked(task_id="task", reason="advisor unavailable")
|
|
473
|
+
with pytest.raises(ValueError):
|
|
474
|
+
ledger.mark_in_progress(
|
|
475
|
+
task_id="task",
|
|
476
|
+
owner_id="new-owner",
|
|
477
|
+
advisor_session_id="new-session",
|
|
478
|
+
base_sha="base",
|
|
479
|
+
)
|
|
480
|
+
with pytest.raises(ValueError):
|
|
481
|
+
ledger.mark_completed(
|
|
482
|
+
task_id="task",
|
|
483
|
+
reviewed_head="head",
|
|
484
|
+
all_changed_paths=(),
|
|
485
|
+
advisor_verdict="ENDORSE",
|
|
486
|
+
all_acceptance_mapping={},
|
|
487
|
+
all_test_evidence=[],
|
|
488
|
+
)
|
|
489
|
+
assert ledger.ledger_path.read_bytes() == previous_bytes
|