fix: v0.9.1 robustness cleanups
- run_resume falls back to a new conversation only when the transcript file is gone; other failures end at the menu with the recorded id intact. - The SessionStart hook resolves the session name under the registry lock; rename holds it across tmux rename-session and the registry move (dd_registry_locked, nestable). - dd_load_env parses KEY=value like systemd instead of sourcing the files. - devdesk-deploy: DPkg::Lock::Timeout, explicit FAILED lines on stdout and in the log; the hook answers synchronously and CI checks the HTTP status. logrotate for the deploy log. - postinst drops duplicate terminal-features entries; hooks sweep stale temp files. Closes #12 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+10
-11
@@ -42,12 +42,21 @@ run_new() {
|
||||
}
|
||||
|
||||
run_resume() {
|
||||
local id args started rc fork=()
|
||||
local id args fork=()
|
||||
id=$(dd_registry_get "$NAME" claude_session_id)
|
||||
if [ -z "$id" ]; then
|
||||
run_new
|
||||
return
|
||||
fi
|
||||
# Start fresh only when the conversation's file is really gone. Any other
|
||||
# failure (an early Ctrl+C, a network error) leaves the recorded id alone
|
||||
# and ends at the menu, where ⏎ tries again (devdesk#12).
|
||||
if [ -z "$(dd_convo_file "$id")" ]; then
|
||||
printf "%sConversation %s is no longer in Claude's history — starting a new one.%s\n" "$MUTED" "$id" "$RESET"
|
||||
sleep 1
|
||||
run_new
|
||||
return
|
||||
fi
|
||||
mapfile -d '' args < <(common_args)
|
||||
# "Fork" from conversation search (devdesk#24): continue as a new
|
||||
# conversation, once; the SessionStart hook then records the new id.
|
||||
@@ -56,17 +65,7 @@ run_resume() {
|
||||
# shellcheck disable=SC2016 # $n is a jq variable
|
||||
dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true
|
||||
fi
|
||||
started=$SECONDS
|
||||
"$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}"
|
||||
rc=$?
|
||||
# A resume that fails straight away means the transcript is gone; start fresh.
|
||||
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
|
||||
printf '%sCould not resume conversation %s — starting a new one.%s\n' "$MUTED" "$id" "$RESET"
|
||||
sleep 1
|
||||
run_new
|
||||
return
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
menu() {
|
||||
|
||||
+32
-9
@@ -3,6 +3,9 @@
|
||||
# listener (hook "devdesk-deploy") when CI publishes a v* tag; the tag's ref is
|
||||
# passed as $1. Safe for running sessions: package upgrades restart only
|
||||
# devdesk.service (ttyd), never devdesk-tmux.service where Claude lives.
|
||||
#
|
||||
# The hook runs this synchronously and returns its output, with HTTP 500 when
|
||||
# it fails, so CI's "Deploy to CT 126" job shows the real result (devdesk#12).
|
||||
set -euo pipefail
|
||||
|
||||
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
|
||||
@@ -12,11 +15,28 @@ log() {
|
||||
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG"
|
||||
}
|
||||
|
||||
exec 9>/run/devdesk-deploy.lock
|
||||
flock -w 900 9 || {
|
||||
log "another deploy is still running; giving up"
|
||||
# The outcome goes to the log and to stdout (the webhook response).
|
||||
say() {
|
||||
log "$*"
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
fail() {
|
||||
say "deploy FAILED: $*"
|
||||
exit 1
|
||||
}
|
||||
on_error() {
|
||||
local rc=$?
|
||||
say "deploy FAILED: line $1 exited $rc (details in $LOG)"
|
||||
exit "$rc"
|
||||
}
|
||||
trap 'on_error $LINENO' ERR
|
||||
|
||||
# Wait for dpkg/apt rather than fail when unattended-upgrades or a manual apt
|
||||
# run holds the lock.
|
||||
APT_OPTS=(-o DPkg::Lock::Timeout=120 -o Dpkg::Use-Pty=0)
|
||||
|
||||
exec 9>/run/devdesk-deploy.lock
|
||||
flock -w 900 9 || fail "another deploy is still running"
|
||||
|
||||
want=${1:-}
|
||||
want=${want#refs/tags/}
|
||||
@@ -25,7 +45,7 @@ before=$(dpkg-query -W -f='${Version}' devdesk 2>/dev/null || echo none)
|
||||
log "=== deploy: installed $before, release ${want:-latest}"
|
||||
|
||||
refresh() {
|
||||
apt-get update -q -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
|
||||
apt-get update -q "${APT_OPTS[@]}" -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
|
||||
-o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
|
||||
}
|
||||
|
||||
@@ -41,14 +61,17 @@ for _ in $(seq 1 18); do
|
||||
done
|
||||
|
||||
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
|
||||
log "registry still offers $candidate, not $want; giving up"
|
||||
exit 1
|
||||
fail "the registry still offers ${candidate:-nothing}, not $want"
|
||||
fi
|
||||
if [ "$candidate" = "$before" ]; then
|
||||
log "already at $before; nothing to do"
|
||||
say "already at $before; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade "${APT_OPTS[@]}" \
|
||||
-o Dpkg::Options::=--force-confold devdesk </dev/null >>"$LOG" 2>&1
|
||||
log "devdesk $before -> $(dpkg-query -W -f='${Version}' devdesk)"
|
||||
after=$(dpkg-query -W -f='${Version}' devdesk)
|
||||
if [ -n "$want" ] && [ "$after" != "$want" ]; then
|
||||
fail "installed $after, not $want"
|
||||
fi
|
||||
say "devdesk $before -> $after"
|
||||
|
||||
@@ -2,15 +2,6 @@
|
||||
# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by
|
||||
# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`.
|
||||
|
||||
dd_projects_dir() {
|
||||
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||
}
|
||||
|
||||
dd_convo_file() {
|
||||
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
|
||||
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
|
||||
}
|
||||
|
||||
# --search QUERY: full-text search over every Claude conversation on this host
|
||||
# (DevDesk and code-server alike). Case-insensitive, plain text. Newest first;
|
||||
# per conversation: title, folder, dates and up to 3 snippets (what you asked,
|
||||
|
||||
@@ -50,8 +50,7 @@ cmd_answer() {
|
||||
dd_transcript() {
|
||||
local id
|
||||
id=$(dd_registry_get "$1" claude_session_id)
|
||||
[[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1
|
||||
compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1
|
||||
dd_convo_file "$id"
|
||||
}
|
||||
|
||||
# --peek SESSION: what the session is doing and the last exchange, from the end
|
||||
|
||||
+74
-20
@@ -8,13 +8,34 @@ DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env}
|
||||
# Local overrides go in env.local: it is never shipped, so upgrades don't prompt about it.
|
||||
DEVDESK_ENV_LOCAL=${DEVDESK_ENV_LOCAL:-${DEVDESK_ENV}.local}
|
||||
|
||||
# The env files are read by systemd (EnvironmentFile=) as well, so they are
|
||||
# parsed here the way systemd parses them, never sourced: KEY=value lines, the
|
||||
# value taken literally (no $ expansion, no command substitution), optionally
|
||||
# in matching quotes. Anything else is reported and skipped (devdesk#12).
|
||||
dd_load_env() {
|
||||
local f
|
||||
for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
|
||||
if [ -r "$f" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$f"
|
||||
fi
|
||||
local _dd_f _dd_line _dd_n _dd_key _dd_val
|
||||
for _dd_f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
|
||||
[ -r "$_dd_f" ] || continue
|
||||
_dd_n=0
|
||||
while IFS= read -r _dd_line || [ -n "$_dd_line" ]; do
|
||||
_dd_n=$((_dd_n + 1))
|
||||
_dd_line=${_dd_line%$'\r'}
|
||||
[[ $_dd_line =~ ^[[:space:]]*([#\;].*)?$ ]] && continue
|
||||
if ! [[ $_dd_line =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
|
||||
printf 'devdesk: %s:%s: not a KEY=value line, ignored\n' "$_dd_f" "$_dd_n" >&2
|
||||
continue
|
||||
fi
|
||||
_dd_key=${BASH_REMATCH[1]}
|
||||
_dd_val=${BASH_REMATCH[2]}
|
||||
_dd_val=${_dd_val%"${_dd_val##*[![:space:]]}"} # trailing blanks, like systemd
|
||||
if [[ $_dd_val =~ ^\"(.*)\"$ || $_dd_val =~ ^\'(.*)\'$ ]]; then
|
||||
_dd_val=${BASH_REMATCH[1]}
|
||||
elif [[ $_dd_val == [\"\']* ]]; then
|
||||
printf 'devdesk: %s:%s: unmatched quote, ignored\n' "$_dd_f" "$_dd_n" >&2
|
||||
continue
|
||||
fi
|
||||
printf -v "$_dd_key" '%s' "$_dd_val"
|
||||
done <"$_dd_f"
|
||||
done
|
||||
}
|
||||
dd_load_env
|
||||
@@ -101,26 +122,59 @@ dd_registry_get() {
|
||||
jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null
|
||||
}
|
||||
|
||||
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
|
||||
dd_registry_update() {
|
||||
local filter=$1
|
||||
shift
|
||||
# dd_registry_locked CMD [ARGS...] — run CMD holding the registry lock, so a
|
||||
# step that reads tmux and then writes the registry (the SessionStart hook,
|
||||
# rename) can't interleave with another. Nests: an update inside reuses it.
|
||||
dd_registry_locked() {
|
||||
if [ -n "${DD_REGISTRY_LOCKED:-}" ]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
mkdir -p "$DEVDESK_STATE_DIR"
|
||||
(
|
||||
flock -w 5 9 || exit 1
|
||||
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
|
||||
local tmp
|
||||
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1
|
||||
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
|
||||
chmod 600 "$tmp"
|
||||
mv -f "$tmp" "$DEVDESK_REGISTRY"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
DD_REGISTRY_LOCKED=1
|
||||
"$@"
|
||||
) 9>"$DEVDESK_REGISTRY.lock"
|
||||
}
|
||||
|
||||
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
|
||||
dd_registry_update() {
|
||||
dd_registry_locked _dd_registry_apply "$@"
|
||||
}
|
||||
|
||||
_dd_registry_apply() {
|
||||
local filter=$1 tmp
|
||||
shift
|
||||
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
|
||||
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || return 1
|
||||
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
|
||||
chmod 600 "$tmp"
|
||||
mv -f "$tmp" "$DEVDESK_REGISTRY"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Claude's conversation files: ~/.claude/projects/<folder>/<id>.jsonl.
|
||||
dd_projects_dir() {
|
||||
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||
}
|
||||
|
||||
dd_convo_file() {
|
||||
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
|
||||
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
|
||||
}
|
||||
|
||||
# Temp files of writers that were killed mid-write (hooks run under Claude's
|
||||
# timeouts): .<session>.XXXXXX and registry.json.XXXXXX, older than 10 minutes.
|
||||
dd_sweep_tmp() {
|
||||
find "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR" -maxdepth 1 -type f -name '.*.??????' -mmin +10 -delete 2>/dev/null
|
||||
find "$DEVDESK_STATE_DIR" -maxdepth 1 -type f -name 'registry.json.??????' -mmin +10 -delete 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
dd_registry_set_dir() {
|
||||
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \
|
||||
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
|
||||
|
||||
@@ -7,14 +7,20 @@
|
||||
# shellcheck source=libexec/devdesk-lib.sh
|
||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0
|
||||
|
||||
session=$(dd_current_session)
|
||||
[ -n "$session" ] || exit 0
|
||||
dd_valid_name "$session" || exit 0
|
||||
|
||||
input=$(cat)
|
||||
id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
|
||||
source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null)
|
||||
[ -n "$id" ] || exit 0
|
||||
|
||||
dd_registry_set_claude "$session" "$id" "$source" 2>/dev/null
|
||||
# The session's name is read under the registry lock: a rename holds it too,
|
||||
# so the id can't land under the old name after the entry moved.
|
||||
# shellcheck disable=SC2317 # called through dd_registry_locked
|
||||
record() {
|
||||
local session
|
||||
session=$(dd_current_session)
|
||||
if [ -z "$session" ] || ! dd_valid_name "$session"; then return 0; fi
|
||||
dd_registry_set_claude "$session" "$id" "$source"
|
||||
}
|
||||
dd_registry_locked record 2>/dev/null
|
||||
dd_sweep_tmp
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user