Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Please choose versions by [Semantic Versioning](http://semver.org/).
* MINOR version when you add functionality in a backwards-compatible manner, and
* PATCH version when you make backwards-compatible bug fixes.

## Unreleased

- fix: `scripts/session-self-exit.py` lets a session that was handed off from a task exit. When `claude_session_id` has moved to another session, the task is resolved through its `metrics_sessions` instead, and that branch skips the terminal check — its own work is already on the record. The pane-ownership and open-card guards still refuse.

## v0.175.0

- feat: `vault-cli goal set` and `topic set` accept the full canonical task phase set (`todo`, `planning`, `execution`, `ai_review`, `human_review`, `done`), and an invalid phase now exits non-zero with the validator's message listing the accepted values, rendered from the enum collection so the list cannot drift. `in_progress` stays a task-only alias and is still rejected.
Expand Down
87 changes: 87 additions & 0 deletions scripts/session-self-exit-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,43 @@ task() { # task <status> <phase> <open-box-count>
} > "$TASKS/Anchor.md"
}

handed_off_task() { # handed_off_task <status> <phase> <current-owner-sid>
local status=$1 phase=$2 owner=$3
{
echo "---"
echo "status: $status"
echo "phase: $phase"
echo "claude_session_id: $owner"
echo "metrics_sessions:"
echo " - session_id: $SID"
echo " started_at: \"2026-10-20T11:51:47.321573+02:00\""
echo " - session_id: $owner"
echo " started_at: \"2026-10-20T11:52:00.000000+02:00\""
echo "---"
echo ""
echo "# Tasks"
echo ""
echo "- [ ] open"
} > "$TASKS/Anchor.md"
}

other_owner_task() { # other_owner_task <owner-sid> — no handoff record for $SID
{
echo "---"
echo "status: in_progress"
echo "phase: execution"
echo "claude_session_id: $1"
echo "metrics_sessions:"
echo " - session_id: $1"
echo " started_at: \"2026-10-20T11:52:00.000000+02:00\""
echo "---"
echo ""
echo "# Tasks"
echo ""
echo "- [ ] open"
} > "$TASKS/Anchor.md"
}

cards() { # cards <json>
printf '%s' "$1" > "$TMP/store/api/1.0/attention"
}
Expand All @@ -68,6 +105,22 @@ run() { # run <want-exit> <label> [extra env...]
fi
}

run_msg() { # run_msg <want-exit> <label> <expected-substring> [extra env...]
local want=$1 label=$2 want_msg=$3
shift 3
local got=0
env -i PATH="$PATH" \
SESSION_ID="$SID" SESSIONS_DIR="$SESSIONS" TASKS_ROOTS="$TASKS" \
WEZTERM_PANE=777 ATTENTION_STORE_URL="$STORE_URL" "$@" \
python3 "$SCRIPT" --dry-run > "$TMP/out" 2>&1 || got=$?
if [ "$got" -eq "$want" ] && grep -qF "$want_msg" "$TMP/out"; then
pass=$((pass + 1))
else
fail=$((fail + 1))
echo "❌ $label: want exit $want and '$want_msg', got $got — $(cat "$TMP/out")" >&2
fi
}

run_live() { # run_live <want-exit> <label> [extra env...] — exercises the kill path, no --dry-run
local want=$1 label=$2
shift 2
Expand Down Expand Up @@ -104,6 +157,40 @@ run 0 "completed + phase done + zero boxes exits"
task aborted planning 3
run 0 "aborted exits even with open boxes"

# --- positive path: handed off to another session
OTHER=99999999-8888-7777-6666-555555555555
handed_off_task in_progress execution "$OTHER"
run_msg 0 "handed-off session exits while the task is still in progress" "SELF-EXIT: exit — handed off to $OTHER"

handed_off_task completed done "$OTHER"
run_msg 0 "handed-off session exits after the task goes terminal" "SELF-EXIT: exit — handed off to $OTHER"

# --- negative path: handed off but holding an open card
cards "[{\"item_id\":\"abc\",\"producer_id\":\"$SID\",\"state\":\"open\"}]"
handed_off_task in_progress execution "$OTHER"
run 10 "handed-off session with an open attention card refuses"
cards '[]'

# --- negative path: another owner and no handoff record for this session
other_owner_task "$OTHER"
run 10 "a task owned elsewhere with no handoff record refuses"

# --- negative path: the id appears only in the task body, never in frontmatter
{
echo "---"
echo "status: completed"
echo "phase: done"
echo "claude_session_id: $OTHER"
echo "---"
echo ""
echo "# Tasks"
echo ""
echo "- [x] done"
echo ""
echo "Session $SID reviewed this task."
} > "$TASKS/Anchor.md"
run 10 "an id present only in the task body refuses"

# --- negative paths: the task is not terminal
task completed planning 0
run 10 "completed but phase is not done"
Expand Down
73 changes: 57 additions & 16 deletions scripts/session-self-exit.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
no entry — it is an in-process SDK query with no pid — and it INHERITS its
spawner's `$WEZTERM_PANE`, so killing that pane would close the manager's
tab. This is the guard that makes the exit safe.
3. The session's anchor task exists, resolved by `claude_session_id`.
3. The session's anchor task exists — resolved by `claude_session_id`, or, when
that id has moved to another session, by the session appearing in the task's
`metrics_sessions` (a recorded handover).
4. The task is terminal on disk: `status: aborted`, or `status: completed`
with `phase: done` and zero `[ ]` / `[/]` boxes. `hold` does not qualify —
it is parked, not finished.
it is parked, not finished. A handed-off session skips this guard: its own
work is on the record, so the successor's progress is not its proof.
5. The attention store holds no open card for this session. An unreachable
store refuses: a failed read is not evidence that no gate is open.

Expand Down Expand Up @@ -109,32 +112,66 @@ def tasks_roots() -> list[str]:
return roots


def read_frontmatter(path: str) -> tuple[dict[str, str], str]:
def read_frontmatter(path: str) -> tuple[dict[str, str], str, str]:
with open(path, encoding="utf-8") as fh:
text = fh.read()
if not text.startswith("---\n"):
return {}, text
return {}, text, ""
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
return {}, text, ""
block = text[4:end]
meta: dict[str, str] = {}
for line in text[4:end].splitlines():
for line in block.splitlines():
key, sep, value = line.partition(":")
if sep:
meta[key.strip()] = value.strip().strip("'\"")
return meta, text[end + 5:]
return meta, text[end + 5:], block


def anchor_task(sid: str) -> tuple[str, dict[str, str], str] | None:
def metrics_session_ids(block: str) -> list[str]:
"""Session ids under `metrics_sessions` — every session that ever worked the task.

The block is a list of maps (`session_id` + `started_at`), which the flat
frontmatter reader above cannot see: it keeps the empty `metrics_sessions:`
line and reads each nested key as a top-level one. The ids are read here.
"""
ids: list[str] = []
in_block = False
for line in block.splitlines():
if not line.strip():
continue
if not line[0].isspace():
in_block = line.partition(":")[0].strip() == "metrics_sessions"
continue
if in_block:
key, sep, value = line.strip().lstrip("- ").partition(":")
if sep and key.strip() == "session_id":
ids.append(value.strip().strip("'\""))
return ids


def anchor_task(sid: str) -> tuple[str, dict[str, str], str, bool] | None:
"""Resolve the task this session may exit against.

A session's own anchor (`claude_session_id`) wins. Failing that, a task the
session was handed off from still counts: the id appears in its
`metrics_sessions` while `claude_session_id` names the successor. The fourth
element says which branch matched, because a handed-off session has already
put its work on the record and does not wait for the task to go terminal.
"""
handed_off: tuple[str, dict[str, str], str, bool] | None = None
for root in tasks_roots():
for path in glob.glob(os.path.join(root, "*.md")):
try:
meta, body = read_frontmatter(path)
meta, body, block = read_frontmatter(path)
except OSError:
continue
if meta.get("claude_session_id") == sid:
return path, meta, body
return None
return path, meta, body, False
if handed_off is None and sid in metrics_session_ids(block):
handed_off = (path, meta, body, True)
return handed_off


def terminal_verdict(meta: dict[str, str], body: str) -> tuple[bool, str]:
Expand Down Expand Up @@ -196,17 +233,21 @@ def main() -> int:
found = anchor_task(sid)
if found is None:
return refuse(f"no task carries claude_session_id {sid} — nothing proves the work is finished")
path, meta, body = found
path, meta, body, handed_off = found

terminal, verdict = terminal_verdict(meta, body)
if not terminal:
return refuse(f"{os.path.basename(path)}: {verdict}")
if handed_off:
reason = f"handed off to {meta.get('claude_session_id') or '(none)'}"
else:
terminal, verdict = terminal_verdict(meta, body)
if not terminal:
return refuse(f"{os.path.basename(path)}: {verdict}")
reason = f"{os.path.basename(path)} is {verdict}"

clear, why = gate_state(sid)
if not clear:
return refuse(why)

print(f"SELF-EXIT: exit — {os.path.basename(path)} is {verdict}, no open card, pane {pane}")
print(f"SELF-EXIT: exit — {reason}, no open card, pane {pane}")
if args.dry_run:
return EXIT_TAKEN

Expand Down
Loading