Skip to content

Commit 55f69c1

Browse files
committed
fix(development-system): make local checkpoints executable
Provide one repository-owned locked compare-and-swap writer, define stable fallback checkpoint identities, and make restart owner selection depend on publication authority across every delivery mode. Closes: 20260901-xige
1 parent 4c53f06 commit 55f69c1

4 files changed

Lines changed: 100 additions & 14 deletions

File tree

plugins/development-system/components/tiber/skills/tiber/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ relative to this skill file and prefer that launcher before probing `PATH`.
8787
tracked/untracked snapshot algorithm defined by the public
8888
`development-workflow` skill; that skill is the sole schema owner. Do not
8989
invent alternate keys, delimiters, hashes, or state-specific nullability.
90-
For the local owner, also use its task-scoped lock and generation/predecessor
91-
compare-and-swap rule so a stale linked-worktree writer cannot replace a
92-
newer transition.
90+
For the local owner, use the task-scoped lock and generation/predecessor
91+
compare-and-swap writer bundled with the public skill so a stale
92+
linked-worktree writer cannot replace a newer transition; do not synthesize a
93+
second filesystem implementation.
9394
Never put raw logs, credentials, or secrets in the note. On restart or
9495
handoff, use `tiber.show` for the published owner or read the selected local
9596
`.latest` owner, then reconcile the record with Git and forge state before
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
echo "usage: write-local-checkpoint.sh CHECKPOINT_ID EXPECTED_GENERATION EXPECTED_PREDECESSOR RECORD_FILE" >&2
6+
exit 2
7+
}
8+
9+
[[ $# -eq 4 ]] || usage
10+
checkpoint_id=$1
11+
expected_generation=$2
12+
expected_predecessor=$3
13+
record_file=$4
14+
15+
[[ $checkpoint_id =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid checkpoint id" >&2; exit 2; }
16+
[[ $expected_generation =~ ^(0|[1-9][0-9]*)$ ]] || usage
17+
[[ -f $record_file ]] || usage
18+
[[ $(wc -l < "$record_file") -eq 1 ]] || { echo "record must contain exactly one newline-terminated line" >&2; exit 2; }
19+
20+
record=$(<"$record_file")
21+
[[ $record == checkpoint-v1\ * ]] || { echo "record must start with checkpoint-v1" >&2; exit 2; }
22+
json=${record#checkpoint-v1 }
23+
jq -e --argjson generation "$expected_generation" --arg predecessor "$expected_predecessor" '
24+
.generation == $generation and
25+
(if $generation == 0 then .predecessor_sha256 == null else .predecessor_sha256 == $predecessor end)
26+
' <<<"$json" >/dev/null
27+
28+
git_common_dir=$(git rev-parse --path-format=absolute --git-common-dir)
29+
checkpoint_dir="$git_common_dir/development-system/checkpoints"
30+
target="$checkpoint_dir/$checkpoint_id.latest"
31+
lock="$checkpoint_dir/$checkpoint_id.lock"
32+
umask 077
33+
mkdir -p "$checkpoint_dir"
34+
chmod 700 "$checkpoint_dir"
35+
exec {lock_fd}>"$lock"
36+
flock -x "$lock_fd"
37+
38+
if [[ -e $target ]]; then
39+
current_generation=$(sed -n 's/^checkpoint-v1 //p' "$target" | jq -er '.generation')
40+
current_predecessor=$(sha256sum "$target" | cut -d ' ' -f 1)
41+
[[ $expected_generation -eq $((current_generation + 1)) ]] || { echo "stale checkpoint generation" >&2; exit 3; }
42+
[[ $expected_predecessor == "$current_predecessor" ]] || { echo "stale checkpoint predecessor" >&2; exit 3; }
43+
else
44+
[[ $expected_generation -eq 0 && $expected_predecessor == null ]] || { echo "missing checkpoint predecessor" >&2; exit 3; }
45+
fi
46+
47+
temporary=$(mktemp "$checkpoint_dir/.$checkpoint_id.tmp.XXXXXX")
48+
trap 'rm -f -- "$temporary"' EXIT
49+
cp -- "$record_file" "$temporary"
50+
chmod 600 "$temporary"
51+
sync -f "$temporary"
52+
mv -f -- "$temporary" "$target"
53+
sync -f "$checkpoint_dir"
54+
trap - EXIT

plugins/development-system/skills/development-workflow/SKILL.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,24 @@ unavailable native workflow scheduler is never the owner. When task-board
5151
remote publication is authorized, append every transition to the active Tiber
5252
task with `tiber.note.add` (CLI: `tiber note add`). When remote mutation is not
5353
authorized, store the latest record at
54-
`$(git rev-parse --git-common-dir)/development-system/checkpoints/<task-id>.latest`
55-
instead. Create its parent with owner-only permissions and serialize transitions
56-
with an exclusive task-scoped lock in that directory. While holding the lock,
57-
read the current complete record and require the proposed record's
54+
`$(git rev-parse --git-common-dir)/development-system/checkpoints/<checkpoint-id>.latest`
55+
instead. Use the active task ID as `checkpoint-id` when one exists. Otherwise
56+
derive it as lowercase hexadecimal SHA-256 of the exact byte sequence
57+
`baseline_oid`, one NUL byte, and the normalized original user request, and
58+
record that ID in every handoff. Invoke the bundled
59+
`<plugin-root>/scripts/write-local-checkpoint.sh` with that ID, the expected
60+
generation, the expected predecessor digest (or literal `null`), and a file
61+
containing the proposed newline-terminated record. The helper creates the
62+
owner-only parent and serializes transitions with an exclusive task-scoped
63+
lock. While holding the lock, it reads the current complete record and requires
64+
the proposed record's
5865
`generation` to equal the current generation plus one and its
5966
`predecessor_sha256` to equal SHA-256 of the exact current `checkpoint-v1` line;
6067
the bootstrap record uses generation zero and a null predecessor. Reject a
61-
missing or stale predecessor without replacing the current record. Then write
62-
one complete line to a same-directory temporary file, flush it, atomically
63-
rename it over the target, flush the directory, and release the lock; never
64-
append in place. Keep this local file
68+
missing or stale predecessor without replacing the current record. The helper
69+
writes one complete line to a same-directory temporary file, flushes it,
70+
atomically renames it over the target, flushes the directory, and releases the
71+
lock; never synthesize a second writer or append in place. Keep this local file
6572
untracked and out of the content snapshot. Select one owner for the current
6673
delivery mode and never treat an unpublished Tiber transaction as the local
6774
fallback. After every transition, persist exactly one record before the next
@@ -106,8 +113,9 @@ gate receipts are `null`, and CI may be empty, only when `baseline_oid` equals
106113
and `delivery` identifies that same pre-existing baseline. A dirty,
107114
non-baseline, or unreconciled starting worktree is a recovery hold, not a
108115
bootstrap shortcut. Store bounded references rather than raw logs or secrets.
109-
At session start, restart, or handoff, read the selected owner: `tiber.show` for
110-
authorized task publication or the exact local `.latest` file for local-only.
116+
At session start, restart, or handoff, read the selected owner: `tiber.show` when
117+
task publication was authorized for this checkpoint, otherwise the exact local
118+
`.latest` file regardless of repository delivery mode.
111119
Select its latest `checkpoint-v1`
112120
record, verify its generation/predecessor chain when history is available, and
113121
reconcile every identity with current Git and forge state before acting. A

scripts/tests/development-discipline-plugin.bats

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ setup() {
7676

7777
@test "local durable checkpoints reject stale cooperative writers" {
7878
skill="$ROOT/plugins/development-system/skills/development-workflow/SKILL.md"
79+
writer="$ROOT/plugins/development-system/scripts/write-local-checkpoint.sh"
7980

80-
run grep -F "exclusive task-scoped lock" "$skill"
81+
run grep -F "owner-only parent and serializes transitions with an exclusive task-scoped" "$skill"
8182
[ "$status" -eq 0 ]
8283

8384
run grep -F 'generation` to equal the current generation plus one' "$skill"
@@ -88,6 +89,28 @@ setup() {
8889

8990
run grep -F "stale predecessor" "$skill"
9091
[ "$status" -eq 0 ]
92+
93+
repo=$(mktemp -d)
94+
git -C "$repo" init -q
95+
first="$repo/first.record"
96+
second="$repo/second.record"
97+
stale="$repo/stale.record"
98+
printf '%s\n' 'checkpoint-v1 {"generation":0,"predecessor_sha256":null}' >"$first"
99+
100+
run bash -c 'cd "$1" && "$2" work-item 0 null "$3"' _ "$repo" "$writer" "$first"
101+
[ "$status" -eq 0 ]
102+
target="$repo/.git/development-system/checkpoints/work-item.latest"
103+
predecessor=$(sha256sum "$target" | cut -d ' ' -f 1)
104+
printf 'checkpoint-v1 {"generation":1,"predecessor_sha256":"%s"}\n' "$predecessor" >"$second"
105+
106+
run bash -c 'cd "$1" && "$2" work-item 1 "$3" "$4"' _ "$repo" "$writer" "$predecessor" "$second"
107+
[ "$status" -eq 0 ]
108+
current=$(<"$target")
109+
printf 'checkpoint-v1 {"generation":1,"predecessor_sha256":"%s"}\n' "$predecessor" >"$stale"
110+
111+
run bash -c 'cd "$1" && "$2" work-item 1 "$3" "$4"' _ "$repo" "$writer" "$predecessor" "$stale"
112+
[ "$status" -eq 3 ]
113+
[ "$(<"$target")" = "$current" ]
91114
}
92115

93116

0 commit comments

Comments
 (0)