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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +10 -10
- package/bin/devlyn.js +92 -58
- package/bin/engines.js +5 -3
- package/bin/harness-activation.js +212 -19
- package/bin/instruction-templates.json +2 -0
- package/bin/skill-history.json +11 -0
- package/config/skills/_shared/engine-doctor.sh +2 -2
- package/config/skills/_shared/engine-preflight.md +2 -2
- package/config/skills/_shared/managed-storage.md +80 -0
- package/config/skills/_shared/platform-support.py +67 -28
- package/config/skills/_shared/role-config.py +16 -7
- package/config/skills/_shared/runtime-principles.md +1 -1
- package/config/skills/_shared/storage-client.py +31 -7
- package/config/skills/_shared/task-complete.py +1 -1
- package/config/skills/_shared/task-completion.md +2 -76
- package/config/skills/devlyn-engines/SKILL.md +1 -1
- package/config/skills/devlyn-ideate/SKILL.md +2 -2
- package/config/skills/devlyn-ideate/references/executor.md +62 -0
- package/config/skills/devlyn-ideate/references/loop.md +2 -59
- package/config/skills/devlyn-ideate/references/package-format.md +1 -1
- package/config/skills/devlyn-ideate/scripts/acceptance.py +442 -21
- package/config/skills/devlyn-ideate/scripts/queue.py +58 -10
- package/harness/AGENTS.md +2 -2
- package/harness/CLAUDE.md +2 -2
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/engine-doctor.sh +2 -2
- package/native/claude/skills/_shared/engine-preflight.md +2 -2
- package/native/claude/skills/_shared/managed-storage.md +80 -0
- package/native/claude/skills/_shared/platform-support.py +67 -28
- package/native/claude/skills/_shared/role-config.py +16 -7
- package/native/claude/skills/_shared/runtime-principles.md +1 -1
- package/native/claude/skills/_shared/storage-client.py +31 -7
- package/native/claude/skills/_shared/task-complete.py +1 -1
- package/native/claude/skills/_shared/task-completion.md +2 -76
- package/native/claude/skills/devlyn-engines/SKILL.md +1 -1
- package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/claude/skills/devlyn-ideate/references/executor.md +62 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +2 -59
- package/native/claude/skills/devlyn-ideate/references/package-format.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +442 -21
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +58 -10
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +2 -2
- package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +2 -2
- package/native/codex/plugins/devlyn/skills/_shared/managed-storage.md +80 -0
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +67 -28
- package/native/codex/plugins/devlyn/skills/_shared/role-config.py +16 -7
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +31 -7
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +2 -76
- package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/executor.md +62 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -59
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +442 -21
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +58 -10
- package/package.json +1 -11
- package/release.json +98 -68
- /package/config/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
- /package/config/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
- /package/config/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
- /package/native/claude/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
- /package/native/claude/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
- /package/native/claude/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
- /package/native/codex/plugins/devlyn/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
- /package/native/codex/plugins/devlyn/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
- /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;
|
|
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
|
|
1224
|
-
f"
|
|
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
|
|
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
|
|
1305
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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** —
|
|
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=
|
|
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** —
|
|
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:
|
|
@@ -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.
|
|
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
|
|
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]),
|
|
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(
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
477
|
-
|
|
500
|
+
elif not deferring:
|
|
501
|
+
raise SystemExit(128 + signum)
|
|
478
502
|
|
|
479
503
|
try:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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** —
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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)
|