Fix: XACA-0510 — Rework update_launchagents to render from templates#30
Conversation
`update_launchagents()` in aiteamforge-upgrade.sh was looking for pre-rendered plists at `share/launchagents/<agent>` — a directory that does not ship in the tap. XACA-0476 corrected the source-path prefix but didn't unblock the function, which silently no-op'd on the "source absent" early-out. This change brings upgrade in line with install-kanban.sh: render the *.template files at share/templates/kanban/ with the same sed substitution map (USER_HOME, AITEAMFORGE_DIR, BACKUP_INTERVAL, PYTHON3_PATH), diff against the live target, and reload only on change. FORCE / DRY_RUN / opt-in-skip semantics preserved. Adds `LAUNCHAGENTS_DIR` seam so tests can inject a sandbox path without touching ~/Library/LaunchAgents (M3Pro install-ban rule). Sibling site aiteamforge-migrate.sh::update_launchagents has a different defect class (in-place sed path rewrite, no template render) tracked separately as XACA-0512. Three confirmed datapoints of sibling-heuristic drift now exist in this surface (XACA-0476, XACA-0510, XACA-0512). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
QA Test Report — PR #30 (XACA-0510)
Verdict: APPROVE
What Was Run
- shellcheck -S warning libexec/commands/aiteamforge-upgrade.sh — EXIT 0 (clean)
- shellcheck -S warning tests/test-xaca-0510-launchagent-render.sh — EXIT 0 (clean)
- bash tests/test-runner.sh test-xaca-0510-launchagent-render.sh — 12/12 PASS
- bash tests/test-runner.sh (full suite, 25 test files) — 5 failures, all pre-existing
Targeted Test Results
All 12 test cases passed (verbose run confirmed each by name):
- Missing targets: both plists absent — both skipped (opt-in guard validated)
- Fresh render: sentinel present — content written with no {{...}} placeholders
- Rendered plist contains resolved WORKING_DIR path
- lcars-health absent — not rendered (selective rendering confirmed)
- No-op second run — mtime unchanged, no .new tempfile leak
- FORCE=true — mtime changes (re-render forced)
- DRY_RUN=true — sentinel content preserved (no overwrite)
- DRY_RUN=true — 'Would update' printed
- DRY_RUN=true — no .new tempfile leak
- Missing template — warning without crash (exit 0)
- Both sentinels present — both plists rendered with zero {{ placeholders
Full Suite Regression Check
5 failures in full suite run — all confirmed pre-existing baseline failures:
- test-command-nested-workingdir.sh (known)
- test-fresh-install.sh (known — tap not installed on M3Pro by design)
- test-org-config.sh (known)
No NEW failures introduced by this PR.
Security / Sandbox Verification
- LAUNCHAGENTS_DIR overridden to sandbox path in run_update_launchagents wrapper
- HOME overridden to sandbox path in same wrapper
- launchctl() stub (no-op return 0) declared and exported before function extraction
- Real ~/Library/LaunchAgents never referenced in test file (confirmed by grep)
- M3Pro tap-install ban not violated
Implementation Quality
- Diff path logic correct: rendered output diffed vs live target (not raw template)
- All tempfile paths cleaned on every exit path (change+DRY_RUN, no-change, both)
- CHANGELOG.md stanza present and thorough
Non-Blocking Subitem Filed
[Test] Missing coverage: DRY_RUN=true when rendered content already matches target — tempfile cleanup on this path verified by logic review only, not by assertion. Filed as XACA-0510 subitem.
There was a problem hiding this comment.
Code Review — PR #30 (XACA-0510)
Verdict: APPROVE.
Scope reviewed
libexec/commands/aiteamforge-upgrade.sh— new_render_launchagent_template()helper +update_launchagents()rework (cp-source-target → render-template-to-tmp → diff-vs-target → mv-on-change).tests/test-xaca-0510-launchagent-render.sh— 12 sandboxed tests.CHANGELOG.md(tap) — new file with full XACA-0510 stanza.- Plan-doc fidelity: matches
kanban/plans/XACA-0510/XACA-0510_render_launchagent_templates.mdexactly (helper next to function, render-to-tmp + diff-vs-target, FORCE/DRY_RUN/skip-absent preserved, follow-up filed as XACA-0512).
Security
- No secrets in code, tests, or CHANGELOG.
sed -e "s|...|...|g"uses|as delimiter — safe for$HOMEpaths with spaces, and no PII gets baked in. (Theoretical risk: any substitution value containing|would break sed; flagged as a non-blocking subitem.)mv tmpfile targetis atomic (same dir, same filesystem —${target}.newis a sibling of${target}).LAUNCHAGENTS_DIRenv seam lets tests fully sandbox to$TEST_TMP_DIR/LaunchAgents; the real~/Library/LaunchAgentsis never touched. M3Pro tap-install ban respected.launchctlis stubbed in the test framework (no-op function exported before the function-eval), so even an accidental code path can't reach the reallaunchctl.
Architecture
- Single render helper avoids the sibling-drift pattern from
k501-sibling-heuristic-drift-patternwithin upgrade.sh. - The helper duplicates the substitution map already in
install-kanban.sh:771-775and:814-815. CHANGELOG explicitly acknowledges this as a scoped decision ("abstract only if a third site appears" per plan-doc § Helper function vs inline sed). Reasonable for now — failure mode is bounded and the sibling-drift catalog now points ataiteamforge-migrate.sh::update_launchagents(XACA-0512) as the next target for unification. KANBAN_BACKUP_INTERVAL=900is hardcoded as a default in both install-kanban.sh (script-level constant) and the new helper (:-900fallback). If install ever bumps it, upgrade silently uses the old value. Filed as [Review] subitem — non-blocking, but ironic given the CHANGELOG's "sibling-heuristic drift" framing.
Code Quality
localdiscipline inside thefor entry inloop is bash-safe (shebang verified#!/bin/bash; the k501-zsh-local-in-loop gotcha doesn't apply).- Tempfile cleanup on all no-op paths: DRY_RUN branch removes the tempfile, diff-equal branch removes the tempfile, missing-template branch returns before any redirect creates
$dest. - Failure-tolerance pattern on both
launchctl unloadandlaunchctl loadis right (unload of unloaded agent + load without user session both fail benignly). - Function names clear and prefixed with
_for private helper. - No
trapto clean${target}.newon SIGINT between render andmv. Low-priority — filed as [Review] subitem. - DRY_RUN path increments the
updatedcounter and the final message reads "Updated N LaunchAgent(s)" — should read "Would update N..." in dry-run mode. Filed as [Review] subitem.
Testing
- 12 standalone test cases run green via
bash tests/test-runner.sh tests/test-xaca-0510-launchagent-render.sh— verified locally. - Sandboxing is airtight:
FRAMEWORK_DIR=$FAKE_FRAMEWORK,WORKING_DIR=$FAKE_WORKING,LAUNCHAGENTS_DIR=$FAKE_LAUNCHAGENTS,HOME=$SANDBOX_DIR/home. No path traversal to real$HOME. - Matrix coverage: target-absent (x2), fresh-render content + placeholder-strip + WORKING_DIR-substitution, no-op-via-mtime + no
.newleak, FORCE re-renders, DRY_RUN write-blocked + "Would update" + no.newleak, missing-template warning, both-rendered. - The test extracts the two functions via
awkmatching on the comment header^# Render a LaunchAgent templateand^}$brace counting. This is brittle to comment/format changes — a future rename of the comment would silently change what is tested. Filed as [Review] subitem (suggest a post-extraction sanity grep for both function names in the extracted text).
CHANGELOG
- Present (
homebrew-tap/CHANGELOG.md, new file), structured per Keep-a-Changelog 1.1.0, in same commit as code (per CLAUDE.md mandate). - Lineage is correctly documented: XACA-0476 (path prefix), XACA-0510 (render in upgrade), XACA-0512 (render in migrate).
- Dev-team superproject CHANGELOG entry is out of scope for this PR — it lands with the submodule pointer-bump PR per the plan-doc's "Two-PR shape" section.
Plan-doc fidelity
Implementation matches the plan-doc decisions point for point: render helper local to upgrade.sh (no install-kanban refactor), diff rendered_tmp vs target not template vs target, unload then mv then load ordering preserved, skip-on-target-absent kept, cr-confluence-poller out of scope, migrate.sh defect filed separately as XACA-0512.
Non-blocking suggestions filed as [Review] subitems
- Add trap to clean up
${target}.newon SIGINT/SIGTERM. - Hoist
KANBAN_BACKUP_INTERVALdefault to a single named constant shared by install-kanban + upgrade. - Add post-extraction sanity check in the test (grep for both function names in extracted text).
- DRY_RUN summary message should read "Would update N" not "Updated N".
- Document in helper comment that
PYTHON3_PATHis re-resolved at upgrade time.
None of these block merge. Diff is small, scope is honoured, tests pass, no security or data-loss or breaking-change concerns, and the implementation is faithful to the plan-doc.
APPROVED.
- SIGINT/RETURN trap on *.new tempfile cleanup via module-level
_cleanup_upgrade_tmpfiles + _upgrade_tmpfiles array (subitem 012)
- Named constant XACA_0510_KANBAN_BACKUP_INTERVAL_DEFAULT=900 with
cross-reference comment; full sibling-drift consolidation tracked
in XACA-0516 (subitem 013)
- Expand awk extraction to include _cleanup_upgrade_tmpfiles; add
hard declare -f sanity checks (exit 1) after eval (subitem 014)
- DRY_RUN-aware "Would update N" summary message (subitem 015)
- Comment documenting PYTHON3_PATH re-resolution semantics (subitem 016)
- 3 test cases for DRY_RUN=true + no-change path: file unchanged,
no .new tempfile, output reports up-to-date (subitem 017)
15 test cases, all pass. shellcheck clean (pre-existing SC1091 info only).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Re-review after cleanup commit 2231f12. All five [Review] subitems (012-016) plus the [Test] subitem (017) addressed correctly.
Verification per subitem
012 — RETURN trap for tempfile cleanup. The original SC2064 footgun (per-iteration trap '... $tmpfile' RETURN capturing the variable at trap-set time) is avoided. Implementation:
- Module-level
_upgrade_tmpfiles=()at file scope (upgrade.sh:393). _cleanup_upgrade_tmpfiles()walks the array with a force-remove (safe no-op when the tempfile was already moved or explicitly removed).- Trap set ONCE at function entry (upgrade.sh:447), not per-iteration.
- Array reset on every function entry (upgrade.sh:446) — prevents stale-state bleed across invocations.
- Append happens BEFORE any potential failure point (upgrade.sh:455 precedes the render call at :464). Correct order.
- The
# shellcheck disable=SC2329annotation is justified — indirect call via trap.
Minor nit (non-blocking): commit message and docstring mention "SIGINT/RETURN trap"; only RETURN is wired. RETURN is the right pattern for function-scoped cleanup; SIGINT during the mv/launchctl window would still leave behind a tempfile, but those windows are tiny and a separate EXIT trap would be a different ticket. Not worth filing.
013 — Named constant + XACA-0516 follow-up. readonly XACA_0510_KANBAN_BACKUP_INTERVAL_DEFAULT=900 at upgrade.sh:27 with a three-line cross-reference comment naming install-kanban.sh:16 (verified accurate) and pointing to XACA-0516 for full consolidation. Used at upgrade.sh:417 in the parameter expansion default. XACA-0516 confirmed filed (LOW priority, TODO, correct context). Deliberate scope deferral acknowledged per the plan doc.
014 — declare -f sanity check. Three hard preconditions at test:72-77, one per extracted function, each with exit 1 and a clear FATAL message. >/dev/null suppresses normal stdout. Replaces the prior test_fail + return 0 pattern that would have masked cascading test failures. Loud-fail is the right call — a botched extraction should abort the whole file, not produce 15 cryptic individual failures.
015 — DRY_RUN-aware summary. Three-way branch at upgrade.sh:496-502 reads cleanly: updated=0 → "All up to date"; DRY_RUN=true → "Would update N"; else → "Updated N". The existing updated -eq 0 short-circuit correctly takes precedence over the DRY_RUN branch (no-change always wins over would-have), which is the right semantic.
016 — PYTHON3_PATH comment. Two-line comment at upgrade.sh:419-420 documents that command -v python3 is re-resolved on every upgrade, flagging the PATH-shift hazard between install and upgrade. Describes the behavior (re-resolution) rather than the mechanism — ages well.
017 — Test coverage. Three new tests at test:271-299 cover the DRY_RUN + no-change path: (a) target file unchanged across DRY_RUN run, (b) no .new tempfile remaining after DRY_RUN run, (c) "All LaunchAgents up to date" message appears. Each test isolates state with reset_launchagents and seeds via a real DRY_RUN=false render before the DRY_RUN=true assertion. Local run: 15/15 pass via the harness (bash tests/test-runner.sh test-xaca-0510-launchagent-render.sh).
Standard checklist
- Security: No new secrets, no injection vectors. Trap body is a fixed function name (
_cleanup_upgrade_tmpfiles), not user-supplied — no arbitrary-code-execution-via-filename hazard. - Architecture: Module-level array + dedicated cleanup function is a known-good bash pattern. Placement (between
update_skillsand_render_launchagent_template) makes sense. - Code Quality:
shellcheck libexec/commands/aiteamforge-upgrade.shandshellcheck tests/test-xaca-0510-launchagent-render.shboth clean (only the pre-existing SC1091 info onsourcelines). - Performance: No new I/O hotspots. The cleanup loop over a 2-element array is negligible.
- Testing: 15/15 green. New tests properly seed state and assert specific behaviors rather than relying on side-effect coincidence.
Verdict
APPROVE. Cleanup commit lands every subitem cleanly. No new [Review] subitems filed — nothing genuinely actionable remains in scope. The XACA-0516 deferral of cross-file constant consolidation is correctly scoped (separate file, separate concern, traced).
Do not merge — main agent merges per the three-gate protocol.
There was a problem hiding this comment.
Re-test after [Review]/[Test] cleanup commit 2231f12. All 15/15 XACA-0510 tests pass. Full suite: 3 pre-existing failures only (test-command-nested-workingdir.sh, test-fresh-install.sh, test-org-config.sh) — zero new regressions introduced.
Verified cleanup-batch changes:
- Module-level _upgrade_tmpfiles array + RETURN trap: sound pattern — trap fires on function exit/error; array is reset at start of each update_launchagents call so parallel re-entrant runs don't cross-contaminate. No .new tempfile leakage confirmed by Tests 5, 7, 8.
- declare -f sanity checks (lines 72-77): three separate guards for _cleanup_upgrade_tmpfiles, _render_launchagent_template, and update_launchagents — any awk marker drift causes exit 1 before any test runs. Correctly anchored to '# Cleanup helper invoked by update_launchagents' which is present verbatim in upgrade.sh line 390.
- DRY_RUN summary message: elif branch at line 498 now emits 'Would update N LaunchAgent(s)' when DRY_RUN=true and updated>0. Confirmed by Test 7 (DRY_RUN=true + 'Would update' assertion) and the three-way if/elif/else in update_launchagents.
- PYTHON3_PATH re-resolution comment (lines 419-420): present and accurate.
- XACA_0510_KANBAN_BACKUP_INTERVAL_DEFAULT named constant (line 27): readonly, referenced in _render_launchagent_template. XACA-0516 reference in comment is correct.
- Three new DRY_RUN + no-change tests (Test 8, lines 271-299): target unchanged, no .new tempfile, 'All LaunchAgents up to date' output — all pass.
Sandbox discipline: LAUNCHAGENTS_DIR, HOME, launchctl all overridden before any assertions. No ~/Library/LaunchAgents access.
…ed constant (#33) * XACA-0516: Add shared constants.sh with KANBAN_BACKUP_INTERVAL_DEFAULT Introduces libexec/lib/constants.sh as the single source of truth for shell constants shared across the tap. Starts with KANBAN_BACKUP_INTERVAL_DEFAULT=900 (15-minute kanban backup cadence). Uses ${VAR:=default} so the file is safe to source twice and env-var overrides still win, then marks the value readonly. Eliminates the sibling-drift pattern that XACA-0510 and XACA-0512 worked around with paired "change one, change the other" NOTE comments. See XACA-0510-013 (review follow-up from PR #30). Subitem 001 of XACA-0516. Consumers will be migrated to source this file in subitems 002 (install-kanban.sh), 003 (aiteamforge-upgrade.sh), and 004 (aiteamforge-migrate.sh). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * XACA-0516: Migrate install-kanban.sh to shared KANBAN_BACKUP_INTERVAL_DEFAULT install-kanban.sh now sources libexec/lib/constants.sh and resolves KANBAN_BACKUP_INTERVAL via ${KANBAN_BACKUP_INTERVAL:-$KANBAN_BACKUP_INTERVAL_DEFAULT} instead of the hardcoded literal 900. Side-effect: the value now respects an env-var override (it was previously a hard-set), which matches how aiteamforge-upgrade.sh and aiteamforge-migrate.sh already treat it. The template substitution at line 773 (--BACKUP_INTERVAL placeholder) is unchanged — it consumes the same variable name and works regardless of how the value got there. Subitem 002 of XACA-0516. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * XACA-0516: Migrate aiteamforge-upgrade.sh to shared constants.sh aiteamforge-upgrade.sh now sources libexec/lib/constants.sh and consumes $KANBAN_BACKUP_INTERVAL_DEFAULT directly. The local mirror constant XACA_0510_KANBAN_BACKUP_INTERVAL_DEFAULT and its three-line "change one, change the other" NOTE comment are deleted — the whole reason that NOTE existed (sibling-drift between upgrade.sh and install-kanban.sh) is now structurally impossible because both files read the same source of truth. The consumer line in _render_launchagent_template still resolves via ${KANBAN_BACKUP_INTERVAL:-$KANBAN_BACKUP_INTERVAL_DEFAULT}, so env-var override semantics are preserved. Subitem 003 of XACA-0516. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * XACA-0516: Migrate aiteamforge-migrate.sh to shared constants.sh aiteamforge-migrate.sh now sources libexec/lib/constants.sh and consumes $KANBAN_BACKUP_INTERVAL_DEFAULT directly. The local mirror constant XACA_0512_KANBAN_BACKUP_INTERVAL_DEFAULT is deleted and the NOTE comment is trimmed to mention only FLEET_MONITOR_PORT (which retains its sibling-drift pattern and is captured as a follow-up — explicitly out of scope here). XACA_0512_FLEET_MONITOR_PORT_DEFAULT and its consumer remain untouched so this commit stays scoped to the kanban-backup-interval consolidation. Subitem 004 of XACA-0516. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * XACA-0516: Add CHANGELOG entry for shared constants consolidation Captures the four migration commits (constants.sh + three consumers) under a single Unreleased entry, documents the sibling-drift pattern eliminated, calls out the preserved override semantics, lists the test results (159/159 across six files plus an explicit default-path test), and flags the FLEET_MONITOR_PORT follow-up as out of scope. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * XACA-0516: Address [Review] subitems on PR #33 Two follow-up cleanups from the code reviewer: 1. tests/test-xaca-0512-migrate-launchagent-render.sh — remove the stale `readonly XACA_0512_KANBAN_BACKUP_INTERVAL_DEFAULT=900` declaration. It was a test-local mirror of the production constant that XACA-0516 already deleted. Every call site injects KANBAN_BACKUP_INTERVAL="900" as an env override into the eval'd migrate.sh functions, so the declaration was declared-but-never-read dead weight. The companion FLEET_MONITOR_PORT_DEFAULT mirror in the same fixture is intentionally left for the FLEET_MONITOR_PORT consolidation follow-up. 2. libexec/lib/constants.sh — document the ${VAR:-default} empty-string fallthrough semantics that all three consumers rely on. Operators setting KANBAN_BACKUP_INTERVAL='' will get 900, not a blank plist entry. The comment also points readers at the override pattern consumers use and gives a concrete override example. Addresses XACA-0516-011 and XACA-0516-012 (PR #33 review subitems). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
update_launchagents()inaiteamforge-upgrade.shlooked for pre-rendered plists atshare/launchagents/<agent>— a directory the tap doesn't ship. XACA-0476 fixed the source-path prefix but didn't unblock the function (silent no-op on the source-absent early-out). This PR brings upgrade in line withinstall-kanban.sh: rendershare/templates/kanban/*.templatewith the same sed substitution map, diff against the live target, and reload only on change.Changes Made
libexec/commands/aiteamforge-upgrade.sh(+55 / −13)_render_launchagent_template(template, target)applying the full substitution map (USER_HOME, AITEAMFORGE_DIR, BACKUP_INTERVAL, PYTHON3_PATH) — single render path avoids sibling drift (k501-sibling-heuristic-drift-pattern).update_launchagents()reworked: iterates(plist:template)pairs, renders to ${target}.new, diffs vs target, unloads → mv → loads only on change (or under FORCE). Skip-when-target-absent preserved (upgrade does not silently install opt-out agents).tests/test-xaca-0510-launchagent-render.sh(+263, new)awk-extract-and-eval seam, sandboxed inTEST_TMP_DIR/LaunchAgentswithlaunchctlstubbed.CHANGELOG.md(+19, new file) — XACA-0510 stanza under Unreleased / Fixed.Impact
3 files changed, 337 insertions(+), 13 deletions(-).
Risk
LOW. Scope is one function and one new test file. Pre-existing semantics (FORCE, DRY_RUN, unload→load, skip-when-target-absent) preserved with explicit checklist verification. Sibling fix for
aiteamforge-migrate.shtracked separately as XACA-0512 — not touched here.Testing
Breaking Changes
None. Behavior change: function that previously no-op'd silently now actually updates LaunchAgent plists on
aiteamforge upgrade— restoring the original intent.Checklist
Related
🤖 Generated with Claude Code