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.
@@ -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
- "and run in executor-advisor mode for this session's multi-step work. "
21
- "Do not invent a second orchestrator path; follow the skill."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.26.6",
3
+ "version": "8.28.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 every dependency has completed successfully.
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
- True when every dependency is ``completed``.
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