claude-dev-env 8.26.5 → 8.27.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/bin/install.mjs CHANGED
@@ -2038,6 +2038,7 @@ function mergeHooksAtPath(
2038
2038
  pythonCommand,
2039
2039
  selectHooksConfig = hooksConfig => hooksConfig,
2040
2040
  pluginRootDir = CLAUDE_HOME,
2041
+ shouldPruneSourceHooks = false,
2041
2042
  ) {
2042
2043
  const hooksSource = resolvePackageManagedDirectory(
2043
2044
  hooksSourceRoot,
@@ -2045,8 +2046,17 @@ function mergeHooksAtPath(
2045
2046
  );
2046
2047
  const hooksJsonPath = join(hooksSource, 'hooks.json');
2047
2048
  if (!existsSync(hooksJsonPath)) return 0;
2048
- const hooksConfig = selectHooksConfig(JSON.parse(readFileSync(hooksJsonPath, 'utf8')));
2049
+ const sourceHooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
2049
2050
  const settings = loadClaudeSettingsObject(settingsPath);
2051
+ if (shouldPruneSourceHooks && settings.hooks && typeof settings.hooks === 'object') {
2052
+ stripRetiredHookEntries(
2053
+ settings,
2054
+ managedHookScriptRelativePaths(sourceHooksConfig),
2055
+ retiredHookOwnership(),
2056
+ );
2057
+ pruneManagedHooksFromSettings(settings, new Set());
2058
+ }
2059
+ const hooksConfig = selectHooksConfig(sourceHooksConfig);
2050
2060
  const groupCount = mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand);
2051
2061
  writeFileSync(settingsPath, JSON.stringify(settings, null, indentation) + '\n');
2052
2062
  return groupCount;
@@ -2081,6 +2091,7 @@ function mergeCodexHooks(hooksSourceRoot, pythonCommand) {
2081
2091
  pythonCommand,
2082
2092
  selectCodexNativeHooksConfig,
2083
2093
  dirname(CODEX_HOOKS_CONFIGURATION_PATH),
2094
+ true,
2084
2095
  );
2085
2096
  }
2086
2097
 
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
16
16
  import { detectPython } from './install.mjs';
17
17
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
18
18
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
19
- const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
19
+ const INSTALLER_PROCESS_TIMEOUT_MS = 120_000;
20
20
  const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
21
21
 
22
22
  const RETIRED_SKILL_DIRECTORIES = [
@@ -373,7 +373,13 @@ test('installer fault after_settings_write restores prior settings and files', (
373
373
  const { claudeDirectory, rulesFile } = seedPriorInstall(homeDirectory);
374
374
  const priorSettings = readFileSync(join(claudeDirectory, 'settings.json'), 'utf8');
375
375
  const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
376
- writeFileWithParents(codexHooksPath, '{"hooks":{"PreToolUse":[{"matcher":"custom"}]}}\n');
376
+ writeFileWithParents(codexHooksPath, JSON.stringify({
377
+ enabled: false,
378
+ hooks: {
379
+ PreToolUse: [{ matcher: 'custom' }],
380
+ SessionEnd: [{ hooks: [{ type: 'command', command: `python "${join(homeDirectory, '.codex', 'hooks', 'lifecycle', 'session_end_cleanup.py')}"` }] }],
381
+ },
382
+ }) + '\n');
377
383
  const priorCodexHooks = readFileSync(codexHooksPath, 'utf8');
378
384
 
379
385
  const failedRun = runInstaller(homeDirectory, [], {
@@ -26,8 +26,17 @@ Run `codex-compat bridge --surface <name> --payload '<json-object>'`. The bridge
26
26
 
27
27
  The package installer seeds `subagent-model-policy.json` in the agents-home
28
28
  `rules/` directory when the file is absent. It registers one `PreToolUse` group
29
- for `multi_agent_v1__spawn_agent` in `$CODEX_HOME/hooks.json`. Existing Codex
30
- hook groups stay in place. Claude-only hook groups do not enter the Codex file.
29
+ for `multi_agent_v1__spawn_agent` and one for `Agent|Task` in `$CODEX_HOME/hooks.json`.
30
+ Before adding these groups, the installer removes obsolete package-owned hook
31
+ registrations from every event. Custom hook entries and Codex's enabled or
32
+ disabled hook settings stay unchanged. Claude-only hook groups do not enter the
33
+ Codex file.
34
+
35
+ A detached Windows Codex daemon can open a console for each background command.
36
+ If this happens, run local interactive sessions with `codex --no-daemon` and keep
37
+ `codex exec` commands unchanged. [Codex issue #44768](https://github.com/openai/codex/issues/44768)
38
+ describes the symptom. [PR #49164](https://github.com/openai/codex/pull/49164)
39
+ adds the upstream console fix.
31
40
 
32
41
  Edit the installed policy file to change the next routing decision. The resolver
33
42
  loads that path for each invocation.
@@ -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.5",
3
+ "version": "8.27.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