devlyn-cli 4.3.27 → 4.3.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +10 -10
  4. package/bin/devlyn.js +92 -58
  5. package/bin/engines.js +5 -3
  6. package/bin/harness-activation.js +212 -19
  7. package/bin/instruction-templates.json +2 -0
  8. package/bin/skill-history.json +11 -0
  9. package/config/skills/_shared/engine-doctor.sh +2 -2
  10. package/config/skills/_shared/engine-preflight.md +2 -2
  11. package/config/skills/_shared/managed-storage.md +80 -0
  12. package/config/skills/_shared/platform-support.py +67 -28
  13. package/config/skills/_shared/role-config.py +16 -7
  14. package/config/skills/_shared/runtime-principles.md +1 -1
  15. package/config/skills/_shared/storage-client.py +31 -7
  16. package/config/skills/_shared/task-complete.py +1 -1
  17. package/config/skills/_shared/task-completion.md +2 -76
  18. package/config/skills/devlyn-engines/SKILL.md +1 -1
  19. package/config/skills/devlyn-ideate/SKILL.md +2 -2
  20. package/config/skills/devlyn-ideate/references/executor.md +62 -0
  21. package/config/skills/devlyn-ideate/references/loop.md +2 -59
  22. package/config/skills/devlyn-ideate/references/package-format.md +1 -1
  23. package/config/skills/devlyn-ideate/scripts/acceptance.py +442 -21
  24. package/config/skills/devlyn-ideate/scripts/queue.py +58 -10
  25. package/harness/AGENTS.md +2 -2
  26. package/harness/CLAUDE.md +2 -2
  27. package/native/claude/.claude-plugin/plugin.json +1 -1
  28. package/native/claude/skills/_shared/engine-doctor.sh +2 -2
  29. package/native/claude/skills/_shared/engine-preflight.md +2 -2
  30. package/native/claude/skills/_shared/managed-storage.md +80 -0
  31. package/native/claude/skills/_shared/platform-support.py +67 -28
  32. package/native/claude/skills/_shared/role-config.py +16 -7
  33. package/native/claude/skills/_shared/runtime-principles.md +1 -1
  34. package/native/claude/skills/_shared/storage-client.py +31 -7
  35. package/native/claude/skills/_shared/task-complete.py +1 -1
  36. package/native/claude/skills/_shared/task-completion.md +2 -76
  37. package/native/claude/skills/devlyn-engines/SKILL.md +1 -1
  38. package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
  39. package/native/claude/skills/devlyn-ideate/references/executor.md +62 -0
  40. package/native/claude/skills/devlyn-ideate/references/loop.md +2 -59
  41. package/native/claude/skills/devlyn-ideate/references/package-format.md +1 -1
  42. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +442 -21
  43. package/native/claude/skills/devlyn-ideate/scripts/queue.py +58 -10
  44. package/native/codex/.agents/plugins/marketplace.json +1 -1
  45. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  46. package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +2 -2
  47. package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +2 -2
  48. package/native/codex/plugins/devlyn/skills/_shared/managed-storage.md +80 -0
  49. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +67 -28
  50. package/native/codex/plugins/devlyn/skills/_shared/role-config.py +16 -7
  51. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +1 -1
  52. package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +31 -7
  53. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +1 -1
  54. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +2 -76
  55. package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +1 -1
  56. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
  57. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/executor.md +62 -0
  58. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -59
  59. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +1 -1
  60. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +442 -21
  61. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +58 -10
  62. package/package.json +1 -11
  63. package/release.json +98 -68
  64. /package/config/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
  65. /package/config/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
  66. /package/config/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
  67. /package/native/claude/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
  68. /package/native/claude/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
  69. /package/native/claude/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
  70. /package/native/codex/plugins/devlyn/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
  71. /package/native/codex/plugins/devlyn/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
  72. /package/native/codex/plugins/devlyn/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
@@ -15,6 +15,7 @@ import datetime
15
15
  import errno
16
16
  import functools
17
17
  import hashlib
18
+ import itertools
18
19
  import json
19
20
  import os
20
21
  from pathlib import Path
@@ -52,7 +53,7 @@ OBLIGATIONS = [
52
53
  "Work only in this worktree on its owned branch under the installed methodology; never edit the loop package or docs/specs/queue.md.",
53
54
  "Commit any source changes on the owned branch; when none are needed, use inputs_sha without creating an empty commit. Leave no uncommitted or untracked source and remove residue the change created.",
54
55
  "Run `runner` on the committed candidate; review records must bind engine, model, that source and both contract digests.",
55
- "Write `submission` and exit; report a blocker instead of weakening acceptance.",
56
+ "Write `submission` as executor.md says and exit; never weaken acceptance.",
56
57
  ]
57
58
 
58
59
 
@@ -1220,8 +1221,8 @@ def ensure_packet(v, row, path, receipt):
1220
1221
  body = (f"Execute task packet {packet_path}. Work only in {worktree} on its owned branch.\n"
1221
1222
  f"Read {binding['defaults']['path']} and apply it as defaults; explicit user and native owner/project instructions win, except a Devlyn managed block (devlyn:instructions markers), which these defaults replace.\n"
1222
1223
  f"Set DEVLYN_SKILL_DIR={bound_skills / 'devlyn-ideate'} and DEVLYN_SHARED_DIR={bound_skills / '_shared'}.\n"
1223
- f"Follow Executor exchange and Review records in {bound_skills / 'devlyn-ideate/references/loop.md'}.\n"
1224
- f"Meet the packet obligations, run its runner and write {packet['submission']}. Never push, open a pull request or merge.\n")
1224
+ f"Follow {bound_skills / 'devlyn-ideate/references/executor.md'}: meet the packet obligations and write "
1225
+ f"{packet['submission']} as it says. Never push, open a pull request or merge.\n")
1225
1226
  acceptance()["atomic_write"](prompt, body.encode("utf-8"))
1226
1227
  packet["workerPrompt"] = harness_call("file_identity", prompt)
1227
1228
  write_json(packet_path, packet)
@@ -1243,7 +1244,9 @@ def ensure_submission(identity, packet_path, packet, executor):
1243
1244
 
1244
1245
  An attempt's start is recorded before the spawn and its exit after the wait, so a start without an exit may have
1245
1246
  left a live executor: drain observes once and waits if it is active. Only established cessation permits adoption
1246
- or another execution. Where writers cannot be observed the task fails as interrupted-unobservable."""
1247
+ or another execution. Where writers cannot be observed the task fails as interrupted-unobservable. A submission
1248
+ blocked only by infrastructure is retired once its writers are observed to have stopped, and the task waits to run
1249
+ again; where they cannot be observed, it fails the task with its blocker."""
1247
1250
  harness_call("verify_packet", packet, packet_path, SKILLS)
1248
1251
  submission = Path(packet["submission"])
1249
1252
  worktree = Path(packet["worktree"])
@@ -1263,7 +1266,7 @@ def ensure_submission(identity, packet_path, packet, executor):
1263
1266
  except (helper["CompletionError"], OSError) as exc:
1264
1267
  raise LoopError(f"cannot establish that an interrupted execution stopped: {exc}; repair writer observation, then drain again") from exc
1265
1268
  if submission.exists():
1266
- return None
1269
+ return retire_retryable(packet, submission)
1267
1270
  defaults = packet["methodology"].get("defaults") if packet.get("schema_version") == 2 else None
1268
1271
  if packet.get("harness") is None and defaults and not Path(defaults["path"]).is_file():
1269
1272
  # An installation's own copy moves with its updates; only its absence matters, and only before a spawn.
@@ -1301,8 +1304,33 @@ def ensure_submission(identity, packet_path, packet, executor):
1301
1304
  code = child.wait()
1302
1305
  record(log, f"exit {code} (pid {child.pid})")
1303
1306
  if not submission.exists():
1304
- raise Waiting(f"executor exited {code} without a submission (see {output / 'executor.stderr'}); the next drain runs it again")
1305
- return None
1307
+ raise Waiting(f"executor exited {code} without a submission (see executor.stdout and executor.stderr in {output}); "
1308
+ "the next drain runs it again")
1309
+ return retire_retryable(packet, submission)
1310
+
1311
+
1312
+ def retire_retryable(packet, submission):
1313
+ """Adopt a stopped executor's submission, unless only infrastructure blocks it (a kind earlier instructions offered):
1314
+ once its writers are observed to have stopped, that one is kept under a retired name, so no later drain adopts it,
1315
+ and the task waits for the next drain. While a writer is active it waits in place."""
1316
+ if acceptance()["infrastructure_causes"](packet, submission) is None:
1317
+ return None
1318
+ helper = shared("task-complete")
1319
+ try:
1320
+ helper["stopped_writers"](Path(packet["worktree"]))
1321
+ except helper["WriterActive"] as exc:
1322
+ raise Waiting(f"executor reported a cause a later run can clear, but a writer is still active: {exc}; wait for or "
1323
+ "stop the named writer (an editor or shell open in the task worktree counts), then drain again") from exc
1324
+ except (helper["CompletionError"], OSError):
1325
+ return None # Unobservable: another run could start beside a live writer, so the blocker fails the task.
1326
+ causes = acceptance()["infrastructure_causes"](packet, submission) # As it stands now: a writer may have replaced it.
1327
+ if causes is None:
1328
+ return None
1329
+ retired = next(path for number in itertools.count(1)
1330
+ if not (path := submission.with_name(f"submission.retired-{number}.json")).exists())
1331
+ submission.rename(retired)
1332
+ raise Waiting(f"executor reported a cause a later run can clear: {'; '.join(map(one_line, causes))} (see {retired}); "
1333
+ "the next drain runs it again")
1306
1334
 
1307
1335
 
1308
1336
  def terminal_queue(cwd, receipt, identity):
@@ -2148,7 +2176,7 @@ class QueueTests(unittest.TestCase):
2148
2176
  """The attempt's start is durable before the spawn, so a resumed drain observes writers first: it waits while
2149
2177
  an executor lives and adopts its submission, runs the executor once when none did, and fails the task where
2150
2178
  writers cannot be observed. Prediction (H2): that one run exits 0 without writing a submission, so the task waits,
2151
- naming the exit code and executor.stderr, and no submission exists. Before: the drain wrote a stand-in submission
2179
+ naming the exit code and both output files, and no submission exists. Before: the drain wrote a stand-in submission
2152
2180
  with a blocked-infrastructure blocker, which made the task [F]."""
2153
2181
  from unittest import mock
2154
2182
  helper = shared("task-complete")
@@ -2197,10 +2225,11 @@ class QueueTests(unittest.TestCase):
2197
2225
  def unobservable(count):
2198
2226
  raise helper["WritersUnobservable"]("writer observation unsupported on this platform; retain workspace")
2199
2227
 
2200
- stderr = Path(packet["evidence_dir"]) / "executor.stderr"
2228
+ output = Path(packet["evidence_dir"])
2201
2229
  for crash, observe, outcome in ((crash_after_spawn, survivor, (1, 3, None)),
2202
2230
  (crash_before_spawn, lambda count: None,
2203
- (1, 1, f"executor exited 0 without a submission (see {stderr}); the next drain runs it again")),
2231
+ (1, 1, f"executor exited 0 without a submission (see executor.stdout and executor.stderr in "
2232
+ f"{output}); the next drain runs it again")),
2204
2233
  (crash_before_spawn, unobservable, (0, 1, "interrupted-unobservable"))):
2205
2234
  with self.subTest(crash=crash.__name__, observe=getattr(observe, "__name__", "nothing")):
2206
2235
  spawns.clear()
@@ -2221,6 +2250,25 @@ class QueueTests(unittest.TestCase):
2221
2250
  if crash is crash_after_spawn:
2222
2251
  self.assertEqual(read_json(Path(packet["submission"])), {"by": "executor"})
2223
2252
 
2253
+ def test_a_submission_is_retired_only_as_it_stands_once_its_writers_stopped(self):
2254
+ """C5 review 2. Prediction: a writer that replaces an infrastructure-only submission with one carrying a product
2255
+ blocker and exits before the observation leaves that submission adopted (None) in place, so the task fails with
2256
+ its blockers, and nothing is retired. Before: the reading from before the observation retired it and the task
2257
+ ran again."""
2258
+ from unittest import mock
2259
+ submission = self.root / "submission.json"
2260
+ infrastructure = {"kind": "blocked-infrastructure", "detail": "registry unreachable"}
2261
+ product = {"kind": "failed", "detail": "notes format undecided"}
2262
+
2263
+ def write(*blockers):
2264
+ submission.write_text(json.dumps({"schema_version": 1, "task": "l.t", "source_sha": "0" * 40, "blockers": blockers}),
2265
+ encoding="utf-8")
2266
+ write(infrastructure)
2267
+ with mock.patch.dict(shared("task-complete"), stopped_writers=lambda worktree: write(infrastructure, product)):
2268
+ self.assertIsNone(retire_retryable({"task": "l.t", "worktree": str(self.root)}, submission))
2269
+ self.assertEqual((read_json(submission)["blockers"], [path.name for path in self.root.glob("submission*")]),
2270
+ ([infrastructure, product], ["submission.json"]))
2271
+
2224
2272
 
2225
2273
  def main():
2226
2274
  parser = argparse.ArgumentParser(description=__doc__)
package/harness/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- devlyn:instructions:begin sha256=25ed8e19648642bab459f945a6608ea1d7fe217497c29840d49d3d65a6de1092 -->
1
+ <!-- devlyn:instructions:begin sha256=f2206e5ed1c96a6c5fc368836bb041e8e77328048ca617f902ed6fb72dcb742d -->
2
2
  Project-specific instructions outside this managed block take precedence over these defaults.
3
3
 
4
4
  # Project Instructions
@@ -18,7 +18,7 @@ Seven rules govern every change. Cite them by name when a decision touches one.
18
18
  3. **No guesswork** — verify with the actual files, logs, diffs, and run output before forming conclusions. State the falsifiable prediction BEFORE the experiment; record raw results AFTER. Retroactive prediction edits are dishonest.
19
19
  4. **Worldclass** — zero CRITICAL, zero HIGH security/design findings on the shippable path. Any review is one pass in which every reviewer, including each one the user requires, reads the source the checks ran on; fix each finding or reject it with a reason, re-check the fixes and the invariants they touch, and repeat the full review only while a CRITICAL or HIGH finding remains.
20
20
  5. **Best practice** — idiomatic for the language and framework. Use standard primitives; do not hand-roll what the library already provides.
21
- 6. **Optimized** — use efficient code and avoid unnecessary work without sacrificing correctness or the user's requirements.
21
+ 6. **Optimized** — efficient on the resource that matters (the user's time, tokens, attention, runtime) without weakening correctness or requirements. An extra check or process step must beat the simpler path.
22
22
  7. **Production ready** — error states are explicit and visible; behavior under failure is what the user expects, not silent corruption.
23
23
 
24
24
  Three discipline rules govern HOW the principles are applied:
package/harness/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- devlyn:instructions:begin sha256=25ed8e19648642bab459f945a6608ea1d7fe217497c29840d49d3d65a6de1092 -->
1
+ <!-- devlyn:instructions:begin sha256=f2206e5ed1c96a6c5fc368836bb041e8e77328048ca617f902ed6fb72dcb742d -->
2
2
  Project-specific instructions outside this managed block take precedence over these defaults.
3
3
 
4
4
  # Project Instructions
@@ -18,7 +18,7 @@ Seven rules govern every change. Cite them by name when a decision touches one.
18
18
  3. **No guesswork** — verify with the actual files, logs, diffs, and run output before forming conclusions. State the falsifiable prediction BEFORE the experiment; record raw results AFTER. Retroactive prediction edits are dishonest.
19
19
  4. **Worldclass** — zero CRITICAL, zero HIGH security/design findings on the shippable path. Any review is one pass in which every reviewer, including each one the user requires, reads the source the checks ran on; fix each finding or reject it with a reason, re-check the fixes and the invariants they touch, and repeat the full review only while a CRITICAL or HIGH finding remains.
20
20
  5. **Best practice** — idiomatic for the language and framework. Use standard primitives; do not hand-roll what the library already provides.
21
- 6. **Optimized** — use efficient code and avoid unnecessary work without sacrificing correctness or the user's requirements.
21
+ 6. **Optimized** — efficient on the resource that matters (the user's time, tokens, attention, runtime) without weakening correctness or requirements. An extra check or process step must beat the simpler path.
22
22
  7. **Production ready** — error states are explicit and visible; behavior under failure is what the user expects, not silent corruption.
23
23
 
24
24
  Three discipline rules govern HOW the principles are applied:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.27",
3
+ "version": "4.3.29",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -29,7 +29,7 @@ check_binary() {
29
29
 
30
30
  check_adapter() {
31
31
  # $1 = target name
32
- if [ -f "$ADAPTERS_DIR/$1.md" ]; then printf 'yes'; else printf 'no'; fi
32
+ if [ -f "$ADAPTERS_DIR/$1.adapter.md" ]; then printf 'yes'; else printf 'no'; fi
33
33
  }
34
34
 
35
35
  check_executor() {
@@ -47,7 +47,7 @@ for i in "${!TARGETS[@]}"; do
47
47
  kind="${KINDS[$i]}"
48
48
  binary="$(check_binary "${BINARIES[$i]}")"
49
49
  adapter="$(check_adapter "$target")"
50
- executor="$(check_executor "$ADAPTERS_DIR/$target.md")"
50
+ executor="$(check_executor "$ADAPTERS_DIR/$target.adapter.md")"
51
51
 
52
52
  note='-'
53
53
  case "$kind" in
@@ -4,7 +4,7 @@ Used by `/devlyn-ideate` and `/devlyn-engines`: one rule for which engine execut
4
4
 
5
5
  ## Selection
6
6
 
7
- The executor is the `executor` pin in `.devlyn/engines.json` at the checkout root (set with `/devlyn-engines executor <name>`), else the CLI the user opened. A linked worktree or subdirectory has no pin unless one is set there. Pi and Grok have no executor adapter, so a drain from them needs a claude, codex or omp pin. Keys earlier releases wrote for retired pipeline roles (`pair_judge_priority`, `roles`) stay as written and select nothing: a worker profile never becomes the executor.
7
+ The executor is the `executor` pin in `.devlyn/engines.json` at the checkout root (set with `/devlyn-engines executor <name>`), else the CLI the user opened. A linked worktree or subdirectory has no pin unless one is set there. Only Claude and Codex can execute, so a drain from any other CLI needs a claude or codex pin. Keys earlier releases wrote for retired pipeline roles (`pair_judge_priority`, `roles`) stay as written and select nothing: a worker profile never becomes the executor.
8
8
 
9
9
  Before dispatching to the executor, run:
10
10
 
@@ -16,7 +16,7 @@ A pin is a promise. When the pinned engine's CLI is unavailable, the command exi
16
16
 
17
17
  ## Engines
18
18
 
19
- An engine is pinnable when `_shared/adapters/<name>.md` exists and does not declare `executor: no` under `## Role eligibility`. Adapters ship with devlyn-cli releases, and a reinstall replaces `_shared`. An adapter's `## Invocation` says how to start it. On native Windows, use native Node/npm and Python (`python3` on PATH), with Git for Windows Bash for the shipped shell wrapper.
19
+ An engine is pinnable when `_shared/adapters/<name>.adapter.md` exists and does not declare `executor: no`. Adapters ship with devlyn-cli releases, and a reinstall replaces `_shared`. An adapter's `## Invocation` says how to start it. On native Windows, use native Node/npm and Python (`python3` on PATH), with Git for Windows Bash for the shipped shell wrapper.
20
20
 
21
21
  ## Reporting a blocked engine
22
22
 
@@ -0,0 +1,80 @@
1
+ # Managed storage
2
+
3
+ New allocation and warming modes come from enrollment state alone:
4
+
5
+ | `~/.devlyn/storage-client.json` | recovery marker | unenroll journal | Mode |
6
+ |---|---|---|---|
7
+ | absent | absent | not read | standalone (never enrolled) |
8
+ | valid | any | not read | managed; a fenced authority refuses |
9
+ | invalid or unreadable | any | not read | refuse; repair registration |
10
+ | absent | present | verified `retired: true` | standalone (unenrollment complete) |
11
+ | absent | present | absent, unreadable or incomplete | refuse; Open Devlyn to repair its storage registration |
12
+
13
+ An unfinished retirement takes precedence over enrollment; follow its
14
+ operation-specific retained-helper resume to reconcile or cancel as named.
15
+ The journal `storage-client-unenroll.json` is read no-follow for retirement
16
+ recovery and the last two rows. Schema 1, canonical UUIDv7 operation, permanent `storage-client-fence.json`,
17
+ matching operation/root/lock identities and Rust's archive ordering/path
18
+ validation are required. If native root/lock identities or the home path changed,
19
+ completed retirement permits standalone only when `storage`, `control`,
20
+ `harness/state.json` and `storage-client.json` are all absent under the current
21
+ lifecycle root (checked without following links). Live inventory requires repair.
22
+ No repository-wide receipt scan selects mode. Other keys can allocate after
23
+ completed unenrollment; a leftover managed key retains its own recovery command.
24
+
25
+ Existing operations keep their managed identity. Receipts pin the control
26
+ identity; current enrollment verifies the helper before allocation. Recovery uses
27
+ the current verified `storage-client-recovery/pins.json`, including after uninstall
28
+ or unenrollment, and requires its authority binding to match the receipt, except
29
+ for a proven moved task: use its unflagged, operation-specific `recover-storage`
30
+ resume to release it, retain its files, and archive its receipt under `moved/`. Missing
31
+ or invalid pins retain resources and require repair; saved artifacts are never a
32
+ fallback. Enrolled checkout preserves
33
+ `managed_pending`/`managed_owned` and Rust's `storage-allocation.json` subreceipt.
34
+ After an interrupted reply, use the returned `recover-storage` resume. Never
35
+ delete managed receipts or replace an unfinished operation ID.
36
+
37
+ Owner `recover-storage --receipt <receipt.json> --operation-id <saved ID>
38
+ --for-removal` fences allocation and reconciles closure and the exact cache set.
39
+ Never-published allocations are abandoned. Accepted LOCAL_ONLY, FAILED, and PRs
40
+ observed CLOSED without merge share custody/recovery-ref verification and fenced
41
+ removal, retaining task branches and a durable terminal delivery. Release writers
42
+ with `complete --writers-stopped` first. A queue-produced FAILED checkout is
43
+ advanced by `read-tree -m -u <source_sha> <publish_sha>` only when HEAD is at the
44
+ attached publish commit and the index and worktree are exactly at source; other
45
+ differences refuse as dirty.
46
+
47
+ Removal-finished receipts with final COMPLETE, LOCAL_ONLY, FAILED or CLOSED
48
+ delivery move under `devlyn-completion/retired/`, regardless of scratch status.
49
+ Retirement moves scratch and its saved cleanup record with the audit and deletes
50
+ nothing. `complete --writers-stopped` saves the writer release there and retries
51
+ pending scratch, including an interrupted cleanup with no saved result; the next
52
+ allocation retries completed deliveries with released writers. `clean-scratch`
53
+ remains available there and never downgrades CLEAN.
54
+ Saved managed `complete`, `recover-storage` and `clean-scratch` resumes carry an
55
+ operation ID validated under the receipt lock. Without an ID, a locator selects
56
+ its active receipt. An ID selects only that operation, including its verified
57
+ retired receipt; a mismatched ID refuses. Retired paths also remain usable.
58
+ A new allocation may reuse a retired key without disturbing its audit.
59
+
60
+ Retained submodule repositories in the admin directory block removal even when
61
+ the checkout is absent; preserve/publish their source and explicitly resolve
62
+ them. Manual checkout removal is advised only after portable source checks and
63
+ record preservation succeed; changed or new source requires retention. If native
64
+ Windows or unobservable writers then require retention, stop all
65
+ writers, remove the task checkout by hand, keep its branch and receipt, then run
66
+ the returned owner-removal resume. The absent-tree path completes the fenced
67
+ registration removal. Scratch cleanup requires positive allocation closure,
68
+ without a cache-removal fence; actual checkout removal owns that fence, including
69
+ empty cache sets. Missing helpers or uncertain closure retain resources with a
70
+ repair/resume action. Compatible recovery artifacts and Rust's LaunchGate remain
71
+ required while managed obligations exist.
72
+
73
+ Managed allocations currently start cold; `build_dirs` retains each returned cache
74
+ and publication lease ID. Managed warming is deferred. Standalone allocations keep
75
+ the warming behavior in [task-completion.md](task-completion.md).
76
+
77
+ For managed receipts, use the saved `clean-scratch` resume with `--operation-id <saved ID>`.
78
+
79
+ New managed allocations use the recovery ref `refs/devlyn/completed/<id>.<operation>` so branch reuse
80
+ preserves earlier recovery refs.
@@ -327,7 +327,9 @@ if os.name == "nt":
327
327
  self.creating = False
328
328
  self.interrupted_signal = None
329
329
 
330
- def start(self, argv, stdin):
330
+ def start(self, argv, stdin, **popen):
331
+ """Launch argv, a list or a command line passed verbatim, inside this job; `popen` passes cwd, env and
332
+ streams to the enclosed bootstrap, whose target inherits them."""
331
333
  self.handle = _kernel.CreateJobObjectW(None, None)
332
334
  limits = _ExtendedLimits()
333
335
  limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE; no breakaway.
@@ -346,13 +348,15 @@ if os.name == "nt":
346
348
  os.set_handle_inheritable(handle, True)
347
349
  startup = subprocess.STARTUPINFO()
348
350
  startup.lpAttributeList = {"handle_list": inherited}
351
+ # A list travels element by element, a command line verbatim as one element; a mode word tells which.
349
352
  command = [sys.executable, "-I", "-S", str(Path(__file__).resolve()), "--job-bootstrap",
350
- str(inherited[0]), str(inherited[2]), str(inherited[1]), *argv]
353
+ str(inherited[0]), str(inherited[2]), str(inherited[1]),
354
+ *(("line", argv) if isinstance(argv, str) else ("argv", *argv))]
351
355
  # Retain the Popen object even if __init__ raises after native creation.
352
356
  self.child = subprocess.Popen.__new__(subprocess.Popen)
353
357
  self.creating = True
354
358
  try:
355
- self.child.__init__(command, stdin=stdin, startupinfo=startup, close_fds=True)
359
+ self.child.__init__(command, stdin=stdin, startupinfo=startup, close_fds=True, **popen)
356
360
  finally:
357
361
  self.creating = False
358
362
  if self.interrupted_signal is not None:
@@ -424,6 +428,11 @@ if os.name == "nt":
424
428
 
425
429
  def _job_bootstrap(argv):
426
430
  job, allow, ready = map(int, argv[:3])
431
+ mode, command = argv[3], argv[4:]
432
+ if mode == "line":
433
+ command, = command
434
+ elif mode != "argv":
435
+ raise ValueError(f"unknown job target mode {mode!r}")
427
436
  with contextlib.ExitStack() as handles:
428
437
  allow_fd = msvcrt.open_osfhandle(allow, os.O_RDONLY | os.O_BINARY)
429
438
  handles.callback(os.close, allow_fd)
@@ -436,50 +445,77 @@ if os.name == "nt":
436
445
  os.write(ready_fd, b"R")
437
446
  if _read_control(allow_fd, 1) != b"G":
438
447
  raise OSError("Windows job launch was not authorized")
439
- with subprocess.Popen(argv[3:], stdin=sys.stdin.buffer, close_fds=True) as target:
448
+ with subprocess.Popen(command, stdin=sys.stdin.buffer, close_fds=True) as target:
440
449
  os.write(ready_fd, target.pid.to_bytes(4, "little"))
441
450
  return target.wait()
442
451
 
443
452
 
453
+ def _signal_group(child, sig):
454
+ """Send `sig` to the child's process group; False once it has no member this process may signal. An exited leader
455
+ is reaped first: until then it still counts as a member."""
456
+ child.poll()
457
+ try:
458
+ os.killpg(child.pid, sig)
459
+ except ProcessLookupError:
460
+ return False
461
+ except PermissionError: # EPERM: no member this process may signal; with the leader alive the run cannot be ended.
462
+ if child.poll() is None:
463
+ raise
464
+ return False
465
+ return True
466
+
467
+
444
468
  def terminate_tree(child, job=None):
445
469
  if job is not None:
446
470
  job.terminate()
447
471
  return
448
- try:
449
- os.killpg(child.pid, signal.SIGTERM)
450
- except ProcessLookupError:
451
- child.wait()
452
- return
453
- # Reap the leader and retain the grace period for its surviving descendants.
454
- deadline = time.monotonic() + 5
455
- try:
456
- child.wait(timeout=5)
457
- except subprocess.TimeoutExpired:
458
- pass # The leader also needs the final group KILL below.
459
- time.sleep(max(0, deadline - time.monotonic()))
460
- try:
461
- os.killpg(child.pid, signal.SIGKILL)
462
- except ProcessLookupError:
463
- pass # Every member already exited during the grace period.
464
- child.wait()
472
+ # SIGTERM and up to 5 s for the group to leave, then SIGKILL and about 1 s more; what still answers then is dead but
473
+ # unreaped or may not be signalled, which cannot be told apart. A member this process cannot signal, or one that left
474
+ # the session, is not owned; acceptance.py's evidence is safe from it because no check process holds an evidence file.
475
+ for sig, seconds in ((signal.SIGTERM, 5), (signal.SIGKILL, 1)):
476
+ deadline = time.monotonic() + seconds
477
+ alive = _signal_group(child, sig)
478
+ while alive and time.monotonic() < deadline:
479
+ time.sleep(0.01)
480
+ alive = _signal_group(child, 0)
481
+ if not alive:
482
+ return
465
483
 
466
484
 
467
- def run_process(argv, stdin, seconds, *, heartbeat=0):
485
+ def run_process(argv, stdin, seconds, *, heartbeat=0, on_timeout=124, **popen):
486
+ """Run argv as an owned tree (its own POSIX session or Windows job), ended whole however the run ends; `popen`
487
+ passes cwd, env and streams. Returns the exit status, or `on_timeout` once `seconds` pass. On Windows argv may be a
488
+ command line passed verbatim."""
468
489
  child = None
469
490
  job = _WindowsJob() if os.name == "nt" else None
470
491
  previous = {}
492
+ received = [] # Every handled signal; the first ends the run with 128 + it once the tree has ended.
493
+ deferring = False # While set, a signal only waits: a POSIX child's creation and the teardown are not interrupted.
471
494
 
472
495
  def interrupted(signum, _frame):
496
+ received.append(signum)
473
497
  # CPython stores the native handle only after CreateProcess's finally block.
474
498
  if job is not None and job.creating:
475
499
  job.interrupted_signal = signum
476
- return
477
- raise SystemExit(128 + signum)
500
+ elif not deferring:
501
+ raise SystemExit(128 + signum)
478
502
 
479
503
  try:
480
- for sig in (signal.SIGTERM, signal.SIGINT):
481
- previous[sig] = signal.signal(sig, interrupted)
482
- child = job.start(argv, stdin) if job is not None else subprocess.Popen(argv, stdin=stdin, start_new_session=True)
504
+ handled = [signal.SIGTERM, signal.SIGINT]
505
+ if os.name != "nt" and signal.getsignal(signal.SIGHUP) is not signal.SIG_IGN:
506
+ # A closed terminal hangs up the runner, not the child's own session; an inherited ignore (nohup) stays.
507
+ handled.append(signal.SIGHUP)
508
+ for sig in handled:
509
+ previous[sig] = signal.getsignal(sig) # Saved first: a signal right after installation finds it to restore.
510
+ signal.signal(sig, interrupted)
511
+ if job is not None:
512
+ child = job.start(argv, stdin, **popen)
513
+ else:
514
+ deferring = True # Raised inside Popen, a signal would leave the new session unowned.
515
+ child = subprocess.Popen(argv, stdin=stdin, start_new_session=True, **popen)
516
+ deferring = False
517
+ if received:
518
+ raise SystemExit(128 + received[0])
483
519
  started = time.monotonic()
484
520
  next_heartbeat = started + heartbeat
485
521
  if heartbeat:
@@ -492,17 +528,20 @@ def run_process(argv, stdin, seconds, *, heartbeat=0):
492
528
  if seconds and now - started >= seconds:
493
529
  if heartbeat:
494
530
  print(f"[codex-monitored] timeout: elapsed={int(now-started)}s limit={seconds}s", file=sys.stderr, flush=True)
495
- return 124
531
+ return on_timeout
496
532
  if heartbeat and now >= next_heartbeat:
497
533
  print(f"[codex-monitored] heartbeat: elapsed={int(now-started)}s", file=sys.stderr, flush=True)
498
534
  next_heartbeat = now + heartbeat
499
535
  finally:
536
+ deferring = True # Teardown runs to its end.
500
537
  try:
501
538
  if job is not None or child is not None:
502
539
  terminate_tree(child, job)
503
540
  finally:
504
541
  for sig, handler in previous.items():
505
542
  signal.signal(sig, handler)
543
+ if received: # Whenever it came and whatever teardown raised, a signal ends the run.
544
+ raise SystemExit(128 + received[0])
506
545
 
507
546
 
508
547
  if __name__ == "__main__" and os.name == "nt":
@@ -56,13 +56,19 @@ def name(value, field):
56
56
  return value
57
57
 
58
58
 
59
+ INELIGIBLE = re.compile(r"^executor: no\s*$", re.M)
60
+
61
+
62
+ # Named `<engine>.adapter.md`: on a case-insensitive filesystem Claude Code loads `claude.md` as nested instructions.
59
63
  def adapter(engine, shared=SHARED):
60
64
  name(engine, "engine")
61
- path = shared / "adapters" / f"{engine}.md"
65
+ path = shared / "adapters" / f"{engine}.adapter.md"
62
66
  if not path.is_file():
63
- fail(f"no adapter for {engine}; valid: {', '.join(sorted(p.stem for p in (shared / 'adapters').glob('*.md')))}")
67
+ valid = sorted(p.name.removesuffix(".adapter.md") for p in path.parent.glob("*.adapter.md")
68
+ if not INELIGIBLE.search(p.read_text(encoding="utf-8")))
69
+ fail(f"no adapter for {engine}; valid: {', '.join(valid)}")
64
70
  text = path.read_text(encoding="utf-8")
65
- if re.search(r"^executor: no\s*$", text, re.M):
71
+ if INELIGIBLE.search(text):
66
72
  fail(f"{engine} is ineligible as executor")
67
73
  return text
68
74
 
@@ -178,15 +184,18 @@ def self_test():
178
184
  # Pins are validated before any byte changes.
179
185
  ineligible = work / "shared"
180
186
  (ineligible / "adapters").mkdir(parents=True)
181
- (ineligible / "adapters/judge.md").write_text("## Role eligibility\n\nexecutor: no\n", encoding="utf-8")
182
- (ineligible / "adapters/codex.md").write_text("# Codex adapter\n", encoding="utf-8")
187
+ (ineligible / "adapters/judge.adapter.md").write_text("## Role eligibility\n\nexecutor: no\n", encoding="utf-8")
188
+ (ineligible / "adapters/codex.adapter.md").write_text("# Codex adapter\n", encoding="utf-8")
183
189
  path.write_bytes(legacy)
184
- for engine, shared, message in (("grok", SHARED, "no adapter for grok"), ("judge", ineligible, "ineligible as executor"),
190
+ # A refused name lists only the engines that can execute.
191
+ for engine, shared, message in (("grok", SHARED, "no adapter for grok; valid: claude, codex"),
192
+ ("grok", ineligible, "no adapter for grok; valid: codex"),
193
+ ("judge", ineligible, "ineligible as executor"),
185
194
  ("../codex", SHARED, "nonempty identifier")):
186
195
  try:
187
196
  edit(work, engine, shared=shared)
188
197
  except ValueError as exc:
189
- assert "BLOCKED:invalid-engine-config" in str(exc) and message in str(exc), exc
198
+ assert str(exc).startswith("BLOCKED:invalid-engine-config:") and str(exc).endswith(message), exc
190
199
  else:
191
200
  raise AssertionError(f"{engine} was pinned")
192
201
  assert path.read_bytes() == legacy
@@ -17,7 +17,7 @@ Seven rules govern every change. Cite them by name when a decision touches one.
17
17
  3. **No guesswork** — verify with the actual files, logs, diffs, and run output before forming conclusions. State the falsifiable prediction BEFORE the experiment; record raw results AFTER. Retroactive prediction edits are dishonest.
18
18
  4. **Worldclass** — zero CRITICAL, zero HIGH security/design findings on the shippable path. Any review is one pass in which every reviewer, including each one the user requires, reads the source the checks ran on; fix each finding or reject it with a reason, re-check the fixes and the invariants they touch, and repeat the full review only while a CRITICAL or HIGH finding remains.
19
19
  5. **Best practice** — idiomatic for the language and framework. Use standard primitives; do not hand-roll what the library already provides.
20
- 6. **Optimized** — use efficient code and avoid unnecessary work without sacrificing correctness or the user's requirements.
20
+ 6. **Optimized** — efficient on the resource that matters (the user's time, tokens, attention, runtime) without weakening correctness or requirements. An extra check or process step must beat the simpler path.
21
21
  7. **Production ready** — error states are explicit and visible; behavior under failure is what the user expects, not silent corruption.
22
22
 
23
23
  Three discipline rules govern HOW the principles are applied:
@@ -42,7 +42,6 @@ import uuid
42
42
  MAX_REQUEST = 128 * 1024
43
43
  MAX_REPLY = 4 * 1024 * 1024
44
44
  EXCHANGE_SECONDS = 15.0
45
- BUSY_SECONDS = 2.0
46
45
  MANAGED_PENDING = "managed_pending"
47
46
  MANAGED_OWNED = "managed_owned"
48
47
  MANAGED_STATES = frozenset({MANAGED_PENDING, MANAGED_OWNED})
@@ -726,7 +725,9 @@ class Client:
726
725
  def call(self, request):
727
726
  data = encode_request(request) # Freeze validated bytes before retry.
728
727
  expected = REQUESTS[request["op"]][1]
729
- busy_deadline, backoff = time.monotonic()+BUSY_SECONDS, 0.01
728
+ # Behind a busy worker, wait as long as one exchange may take, as devlynd's own clients
729
+ # do (EXCHANGE_BUDGET): one slow request must not fail the call.
730
+ busy_deadline, backoff = time.monotonic()+EXCHANGE_SECONDS, 0.01
730
731
  try:
731
732
  while True:
732
733
  try:
@@ -1113,6 +1114,7 @@ def moved_operation(saved):
1113
1114
  pins = lifecycle_evidence(root, root / 'storage-client-recovery/pins.json', observed)
1114
1115
  if pins is None:
1115
1116
  return None # No retained binding can establish a moved receipt.
1117
+ require_known_pins(pins)
1116
1118
  validate(pins, {'schema': 'u32', 'enrollment': ENROLLMENT_SHAPE, 'runtime': ARTIFACT_SHAPE, 'node': 'str'}, exact=True)
1117
1119
  require(pins['schema'] == pins['enrollment']['schema'] == 1, 'unsupported recovery pins')
1118
1120
  if (all(saved.get(key) == pins['enrollment'][key] for key in ('control_directory', 'control_identity'))
@@ -1174,6 +1176,8 @@ def retirement_refusal(root=None):
1174
1176
  require(fence is not None, 'retirement fence missing')
1175
1177
  record = recovery_registration()
1176
1178
  except (StorageRefusal, OSError, ValueError, RuntimeError) as exc:
1179
+ if isinstance(exc, StorageRefusal) and exc.code == 'storage_unsupported':
1180
+ raise # An update answers it; Devlyn can repair nothing a newer Devlyn wrote.
1177
1181
  detail = exc.diagnostic if isinstance(exc, StorageRefusal) else str(exc)
1178
1182
  repair = 'Open Devlyn to repair its storage registration'
1179
1183
  raise StorageRefusal('storage_unavailable', detail, phase='enrollment', message=repair, resume=repair) from exc
@@ -1188,6 +1192,22 @@ def retirement_refusal(root=None):
1188
1192
  message='Finish this storage retirement; resume ' + resume + alternative, resume=resume)
1189
1193
 
1190
1194
 
1195
+ def require_known_pins(pins):
1196
+ """Pins a newer Devlyn wrote refuse as unsupported, as the managed read's newer record does, never as pins to
1197
+ repair: only that Devlyn may change them."""
1198
+ enrollment = pins.get('enrollment') if type(pins) is dict else None
1199
+ schemas = (pins.get('schema') if type(pins) is dict else None,
1200
+ enrollment.get('schema') if type(enrollment) is dict else None)
1201
+ require(not any(type(schema) is int and schema > 1 for schema in schemas),
1202
+ 'recovery pins from a newer Devlyn', 'storage_unsupported')
1203
+
1204
+
1205
+ def invalid_pins(exc):
1206
+ detail = exc.diagnostic if isinstance(exc, StorageRefusal) else str(exc)
1207
+ return StorageRefusal('storage_unavailable', f'recovery pins invalid: {detail}', phase='enrollment',
1208
+ message='Retain the workspace and receipt; repair Devlyn recovery pins and retry')
1209
+
1210
+
1191
1211
  def recovery_registration():
1192
1212
  """Current retained pins survive replacement of the app and unenrollment."""
1193
1213
  try:
@@ -1199,6 +1219,10 @@ def recovery_registration():
1199
1219
  require(info is not None and stat.S_ISDIR(info.st_mode), 'recovery pins directory missing/invalid')
1200
1220
  observed = [(marker, info)]
1201
1221
  pins = read_evidence_json(marker / 'pins.json', observed)
1222
+ except (StorageRefusal, OSError, ValueError, RuntimeError) as exc:
1223
+ raise invalid_pins(exc) from exc
1224
+ require_known_pins(pins)
1225
+ try:
1202
1226
  validate(pins, {'schema': 'u32', 'enrollment': ENROLLMENT_SHAPE,
1203
1227
  'runtime': ARTIFACT_SHAPE, 'node': 'str'}, exact=True)
1204
1228
  record = pins['enrollment']
@@ -1216,9 +1240,7 @@ def recovery_registration():
1216
1240
  require(after is None if before is None else _same_file(before, after), f'recovery pins changed: {path}')
1217
1241
  return record
1218
1242
  except (StorageRefusal, OSError, ValueError, RuntimeError) as exc:
1219
- detail = exc.diagnostic if isinstance(exc, StorageRefusal) else str(exc)
1220
- raise StorageRefusal('storage_unavailable', f'recovery pins invalid: {detail}', phase='enrollment',
1221
- message='Retain the workspace and receipt; repair Devlyn recovery pins and retry') from exc
1243
+ raise invalid_pins(exc) from exc
1222
1244
 
1223
1245
 
1224
1246
  def windows_profile():
@@ -1480,7 +1502,9 @@ def queue_handoff(common, binding):
1480
1502
  helper = runpy.run_path(str(Path(__file__).with_name('task-complete.py')))
1481
1503
  text += ' resume ' + helper['receipt_resume']('recover-storage', receipt, saved)
1482
1504
  holders.append(text)
1483
- except (StorageRefusal, OSError, ValueError, TypeError, AttributeError, KeyError):
1505
+ except (StorageRefusal, OSError, ValueError, TypeError, AttributeError, KeyError) as exc:
1506
+ if isinstance(exc, StorageRefusal) and exc.code == 'storage_unsupported':
1507
+ return f'{receipt}: {exc}' # Evidence a newer Devlyn wrote: update, never repair.
1484
1508
  if adapter_marker:
1485
1509
  holders.append(str(receipt))
1486
1510
  continue
@@ -1491,7 +1515,7 @@ def queue_handoff(common, binding):
1491
1515
  return drain if managed else None
1492
1516
  return ('this queue waits for the tasks in progress here: ' + '; '.join(holders)
1493
1517
  + ". Finish each with this release, and follow any resume a refusal names; a moved task's resume releases it and keeps its files; "
1494
- + '`recover-storage --for-removal` removes one only when the owner says so (task-completion.md); '
1518
+ + '`recover-storage --for-removal` removes one only when the owner says so (managed-storage.md); '
1495
1519
  + ('then start it with Drain in Devlyn' if managed else 'then run this again'))
1496
1520
 
1497
1521
 
@@ -328,7 +328,7 @@ def storage_call(name, *args, adapter=False, receipt_path=None, resume=None, **k
328
328
  if resume:
329
329
  if resume not in message:
330
330
  message += '; resume ' + resume
331
- else:
331
+ elif exc.code != 'storage_unsupported': # An update is that refusal's remedy, never a repair.
332
332
  message += '; repair Devlyn enrollment and retry'
333
333
  error = CompletionError(message)
334
334
  error.storage_error = exc.result(receipt=path, resume=resume)