fix(apps): stop reading a broken metadata path as "app not installed" - #8587
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Real, asymmetric harm (agent-file deletion, live-app teardown) fixed at the tri-state contract's owner, with fail-to-unknown confirmation at the one consumer that destroys state. Watch
[DESIGN-REVIEWED] 0db5c24 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts and claims verified. The description's numbers hold up ( First-Principles-Verdict: CONCERNS Cause-level fix, but generalizing What this change shipsIntent: stop a broken-but-present metadata path from being acted on as a deliberate uninstall — a FIX (three declared defects).
Watch
SubtractionsDrop the [FIRST-PRINCIPLES-REVIEWED] 0db5c24 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
c326257 to
d5d874d
Compare
d5d874d to
9d27c39
Compare
9d27c39 to
6b8334d
Compare
6b8334d to
94759b9
Compare
94759b9 to
cd47951
Compare
6feefa2 to
51bd5d0
Compare
51bd5d0 to
deaf5ef
Compare
deaf5ef to
86d795e
Compare
86d795e to
da80eb4
Compare
da80eb4 to
5f6f7fe
Compare
5f6f7fe to
b74d348
Compare
Two residual gaps around the hook reconciler #7892 added, both reachable from a CLI-driven lifecycle change that the gateway only learns about second-hand. app_enabled_state exists to keep "not installed" apart from "could not be read", because the first is a reason to delete an app's runtime and the second is not. It then led with Path.is_file(), which answers a silent False for five path shapes that are not absence -- verified against this interpreter: a dangling symlink, a directory in the file's place, a fifo in its place, a symlink loop (ELOOP), and a non-directory parent component (ENOTDIR). Each read as a deliberate uninstall. A genuine stat fault such as EACCES was already correct, since is_file re-raises that and the existing handler turns it into None; the bug was path shapes, not permissions. The callers that already respect the tri-state are what make this worth fixing. apps.backend reads it before DELETING materialized resources -- _drop_disabled_app_resources on a False, _undo_promotion_of_disabled_app likewise -- and its own comments say a None "must not be collapsed into disabled" and is retried instead. That contract was written correctly; this function did not honour it, so a dangling symlink or a directory in the metadata's place deleted an app's agent files. Only nothing being at the path is absence now. The reconciler #7892 added did NOT read this function, and an earlier revision of this commit claimed it did. Its teardown decides "gone" from get_app -> _read_installed, which has the same Path.is_file() collapse and additionally folds a corrupt JSON body into None -- so a broken path unloaded a healthy app's routes and modules every tick until the fault cleared, and with this commit's registry drop it would take the app's disable and slot-close hooks with it. _read_installed has 24 callers and get_app/list_apps 63, so it is not made tri-state here; the reconciler confirms absence through app_enabled_state instead and defers the whole app on unknown, which is the direction _disable_loaded already takes when startup ownership cannot be proven clear. Absence is decided from the path's SHAPE, not from the exception class, because one condition does not produce one class across platforms. A non-directory parent component raises NotADirectoryError (ENOTDIR) on POSIX but FileNotFoundError on Windows, which maps ERROR_PATH_NOT_FOUND to ENOENT -- the same class a genuinely missing file raises. The first version of this fix keyed "definitely not installed" on that class, so it told the truth on Linux and not on Windows, where a wrong-shape parent still read as a deliberate uninstall and the reconciler would still tear a live app down for it. Its own Windows test caught that. _absence_is_genuine now walks to the nearest existing ancestor and requires it to be a directory, which is platform-independent; _spawn_exec_shim records the same lesson for chdir ("the errno is not the thing to key on"). Both classes are exercised on every platform by injecting the Windows mapping, with a control that genuine absence still reports False -- uninstall depends on that. The reconciler also never dropped an app's in-process hook registries on uninstall. forget_app_hooks had exactly one caller -- the dashboard uninstall handler -- so a CLI uninstall reached the reconciler's teardown branch and left them behind: a closure over a store the uninstall deleted, whose failure notify_slot_closed reports and api_chat_slot_delete turns into a tab the user cannot dismiss for an app that no longer exists. Called on the UNINSTALL shape only, matching the asymmetry forget_app_hooks documents -- these registries are repopulated from each app's own watchdog rather than by the gateway, so clearing them on a disable would leave a window after a re-enable in which a dismissal silently fails to reach a live worker. Refs #7926
dwu96
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: clear root cause - app_enabled_state led with Path.is_file(), so five path shapes that are not absence (dangling symlink, directory or fifo in the file's place, symlink loop, non-directory parent) read as a definite 'not installed'; the fix decides absence from the path shape rather than the errno class and makes the hook reconciler defer on unknown instead of unloading a healthy app.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: app_enabled_state led with Path.is_file(), which answers a silent False for five shapes that are not absence (dangling symlink, dir/fifo in the file's place, ELOOP, non-directory parent) -- so a broken metadata path read as uninstalled and the 15s hook reconciler unloaded a healthy app's routes and modules; absence is now decided from the path shape, unknown defers to the next tick, and forget_app_hooks is cleared on uninstall only.
What is the problem?
Two residual gaps around the hook reconciler that #7892 added, both reachable
from a CLI-driven lifecycle change that the gateway only learns about
second-hand.
1.
app_enabled_statereads a bad metadata path as "app not installed".That function exists to keep "not installed" apart from "could not be read",
because the first is a reason to delete an app's runtime and the second is not.
Its own docstring says so. It then led with
Path.is_file(), which answers asilent
Falsefor five path shapes that are not absence. Measured against thisinterpreter rather than assumed:
Path.is_file()FalseFalseFalseFalseFalseThe last row is the boundary and it matters: a genuine
statfault was alwaysreported correctly, because
is_filere-raises it and the existing handler turnsit into
None. The bug is path SHAPES, not permissions.And the verdict cannot be read off the exception class, because one condition does
not produce one class across platforms. A non-directory parent component raises
NotADirectoryError(ENOTDIR) on POSIX butFileNotFoundErroron Windows, whichmaps
ERROR_PATH_NOT_FOUNDto ENOENT -- the same class a genuinely missing fileraises. The first version of this fix keyed "definitely not installed" on that
class, so it was correct on Linux and still wrong on Windows; its own Windows test
caught it, failing with
assert False is None.Review then found the same mistake one predicate over, and it is the general form of
this defect: I keyed a decision on something whose meaning changes by platform.
is_symlinkis False for a Windows directory junction, so a DANGLING junctionpresents as
is_dir=False, exists=False, is_symlink=False-- indistinguishable fromnothing at all -- and the ancestor walk stepped over the thing occupying the path and
reported genuine absence. That answer is the escalation this PR exists for: with
#7892's reconciler now a live consumer, a
Falsehere is not a cosmetic misreport,it unloads a RUNNING app's routes and modules and lets
apps.backenddelete itsmaterialized agent files. The repo had already built the answer --
is_link_or_junctionin
platform_compat, 131 call sites outside its own module, whose docstring states thepremise: "os.path.islink returns False for a junction, so a caller that only checks
islink would treat a junction as a real directory." This was the one call site that
skipped it. The first fix was correct and incomplete, not wrong.
2. A CLI uninstall never drops the app's in-process hook registries.
forget_app_hookshad exactly one caller -- the dashboard uninstall handler --so a CLI uninstall reached the reconciler's teardown branch and left them behind.
3. The reconciler's own "gone" verdict has the same collapse. It decides teardown
from
get_app->_read_installed, which leads with the identicalPath.is_file()check and additionally folds a corrupt JSON body into
None. So a broken pathunloaded a healthy app's routes and modules every tick until the fault cleared -- and
with gap 2's registry drop, would take its disable and slot-close hooks with it.
Why this issue matters to the user
Gap 1's cost is asymmetric, and the callers that already respect the tri-state are
what make it worth fixing.
apps.backendreads it before deleting materializedresources --
_drop_disabled_app_resourceson aFalse, and_undo_promotion_of_disabled_applikewise -- and its own comments say aNone"must not be collapsed into disabled" and is retried instead. That contract was
already written correctly; this function did not honour it, so a dangling symlink or
a directory in the metadata's place deleted an app's agent files.
Gap 3 is where the unattended harm lives: the 15s reconciler tears a live app's
routes and modules down on a misread, repeatedly. An earlier revision of this PR
attributed that harm to
app_enabled_stateand was wrong -- the reconciler nevercalled it. Three reviewers caught the same error; the fix now makes the claim true
rather than deleting it.
Gap 2 leaves the user with a tab they cannot get rid of. The surviving
slot-close hook is a closure over a store the uninstall deleted, so it raises;
notify_slot_closedreports that failure rather than swallowing it, andapi_chat_slot_deleterefuses the dismissal on a false return. The app is goneand its tab stays.
How our fix solves it
stat()is called directly so it raises instead of lying, and the absent branchis then confirmed structurally rather than by error class:
_absence_is_genuinewalks to the nearest existing ancestor and requires it to be a directory. A
dangling symlink is
None(a path that exists whose target cannot be seen), anyother
OSErrorisNone, and a successfulstaton a non-regular file isNone. That makes every one of the six rows above give the same answer on POSIXand Windows. No behaviour change for a healthy record, and genuine absence is
still a definite
False-- uninstall depends on it.get_appreturningNoneit readsapp_enabled_stateoff-loop and proceeds only on a definiteFalse; unknown defers the whole app to the next tick, the same direction_disable_loadedalready takes when startup ownership cannot be proven clear._read_installedis deliberately NOT made tri-state -- it has 24 callers andget_app/list_apps63, which is a far wider change than this PR should carry.forget_app_hookson the UNINSTALL shape only, in the reconciler'steardown branch where
goneis already computed. The asymmetry is the oneforget_app_hooksdocuments: these registries are repopulated from each app'sown watchdog rather than by the gateway, so clearing them on a DISABLE would
leave a window after a re-enable in which a dismissal silently fails to reach a
live worker. Gated on a settled teardown, so an app whose code is still running
keeps its own off-switch and the reconciler drops the registries on the retry
that settles.
Every path predicate in these two functions, enumerated. Review found this class
twice -- once on the ancestor walk, once on the metadata path itself -- so the useful
statement is not "one more instance is fixed" but "there are four, and here is why
three of them were never wrong":
app_enabled_statemeta_path.stat()app_enabled_statemeta_path.is_symlink() or is_link_or_junction(meta_path)Path.parentsexcludes the path itself, so the ancestor walk can never reach a junction sitting ONinstalled.jsonapp_enabled_statestat.S_ISREG(st.st_mode)statSUCCEEDED, so the path resolved and there is no dangling reparse point left to miss_absence_is_genuineancestor.is_dir()_absence_is_genuineancestor.exists() or ancestor.is_symlink() or is_link_or_junction(ancestor)The general form of the defect, stated once: a decision must not be keyed on
something whose meaning changes by platform. The exception class was the first
instance,
is_symlinkversus a junction the second and third. Each earlier fix wascorrect and incomplete rather than wrong.
What tests we did
test/test_app_manager.py-- five tests, one per measured shape, plus thealready-correct EACCES boundary kept deliberately so the distinction is pinned,
plus a control that a healthy record still reports its own
enabledflag.test/test_hook_reconcile.py-- three tests on main's own reconciler harness:an uninstall makes a previously-refused slot close succeed, a plain disable
leaves the app's hook reachable and consulted, and an unsettled uninstall keeps
it for the retry.
Ten mutation probes, each killed:
Path.is_file()assert False is None-- byte-identical to the Windows CI failureassert False is Noneagain -- the same signature one predicate overassert False is Nonea third time -- the same class on the path itselfforget_app_hooksfrom the reconcilertest_app_manager.py+test_hook_reconcile.py: 179 passed. The FIFO case carries anexplicit
skipif(not hasattr(os, "mkfifo"))with its reason -- a FIFO is a POSIX-onlypath shape, so there is nothing to assert on Windows; the non-directory-parent case is
NOT skipped, because that shape exists on both platforms and skipping it would have
hidden the defect above. The dangling junction is fed as a SHAPE rather than a real
junction, for the same reason inverted: a junction has no POSIX equivalent at all, so
requiring one would leave the case exercised only on the platform it breaks. A path
that does not exist is already False for all three ordinary predicates, which IS the
dangling junction's shape, so only the junction probe is stood in for. isort, flake8, mypy, the black gate and the sync-io-in-async gate all
clean;
test_app_manager.pyis in the black baseline, so it was edited withoutrunning black over it (a bare run reformats 39 unrelated pre-existing lines).
Manual verification
#7926's symptom is already fixed onmainby #7892, verified before rescopingthis PR: a throwaway worktree at
origin/mainwith none of this branch's codepresent, driving
hook_reconcile.reconcile_oncefrom a two-process harnessthat uses the real CLI as a subprocess against the same
KIROCREW_HOME:app disableapp uninstallThat is why this PR says
Refs #7926and notCloses-- #7892 fixed thereported issue, and this carries only the two gaps that survived it.
Any other suggestions on the work
This PR previously proposed a second, independent reconciliation sweep for the
same symptom. #7892 landed the mechanism first, so that work was dropped rather
than merged alongside it: a fix that is not needed is cheaper to drop than to
maintain. What remains are the two defects that #7892 did not cover, and one of
them is more dangerous because of it.
Pattern harvest
Rule candidate: when a tri-state read exists specifically to separate "absent"
from "unknown", the absent branch must be confirmed from the path's SHAPE, not from
a predicate like
Path.is_file()that collapses several failure modes intoFalseand not from the exception class either -- one filesystem condition does not map to
one class across platforms, so a class-keyed branch is a platform check wearing a
semantic disguise. This repository already learned that for
chdir(
_spawn_exec_shim: "the errno is not the thing to key on") and the same trap waswalked into here.