TL;DR
Two chained bugs in kbagent sync make GitOps workflows unusable on gitBranching.enabled: true workspaces:
-
Bug A — kbagent sync pull mis-routes dev-branch configs into main/ and corrupts .keboola/manifest.json by appending a duplicate branches[] entry on every invocation. Root cause: Keboola branch IDs are persisted as str in branch-mapping.json but compared as int everywhere else, so every cross-type == lookup silently fails.
-
Bug B — kbagent sync diff and kbagent sync push --dry-run silently report "no changes" when the user scaffolds a brand-new local config dir under a non-default branch path. The "scaffold locally then push" flow advertised by the docs is unreachable on git-branching workspaces.
The two bugs are independent at the symptom level but Bug A blocks a complete fix for Bug B (the path-resolution helper that Bug B's walker delegates to is itself broken by Bug A). They should be fixed together in one PR.
Reported externally against v0.27.0; reproduced in code review on v0.30.3 (current main). Both bugs are still latent.
Shared environment
- Manifest schema v3,
gitBranching.enabled: true, defaultBranch: main.
- Active git feature branch linked via
kbagent sync branch-link --branch <DEV_BRANCH_ID> to a non-default Keboola dev branch.
- Manifest pre-state:
"branches": [
{ "id": 38516, "path": "main", "metadata": {} },
{ "id": 38721, "path": "branch-38721", "metadata": {} }
]
Bug A — sync pull mis-routes to main/ and inflates branches[]
Symptom
Single warning line on stderr:
WARNING Branch ID 38721 not found in manifest, falling back to default path
— even though {"id": 38721, "path": "branch-38721"} is plainly present in manifest.branches[]. The lookup fails, the fallback writes to main/, and a separate code path then re-registers the "missing" branch under a mangled path.
Repro
kbagent --json sync pull --project <alias>
- The warning above is emitted.
- JSON response indicates a successful pull, but
branch_dir: "main":
{ "status": "ok",
"data": {
"status": "pulled",
"branch_id": "38721",
"branch_dir": "main",
"configs_pulled": 1,
"details": [
{ "action": "updated",
"component_id": "<component-id>",
"path": "application/<component-id>/<config-name>" } ] } }
- Files appear on disk at
main/application/<component-id>/<config-name>/ instead of branch-38721/....
manifest.branches[] grows by one entry per pull:
{ "id": 38721, "path": "branch-38721-38721", "metadata": {} }
Each subsequent sync pull appends another duplicate (branch-38721-38721-38721, etc.).
manifest.configurations[] records "branchId": 38721 with the correct relative path for the dev branch — so the manifest now misdescribes where files actually live.
Root cause — branch_id is str in branch-mapping.json, int everywhere else
branch_link converts the Keboola branch ID to a string before persisting it. services/sync_service.py lines 1957, 1967, 1971, 1981, 1985:
kbc_branch_id = str(branch_info["id"]) # all five branches of branch_link
That string lands on BranchMappingEntry.keboola_id, whose annotation in sync/branch_mapping.py:19 is already str | None — the bug is locked in by the type annotation.
_resolve_branch_id (services/sync_service.py:2085) is annotated -> int | None but actually returns the string straight from the mapping (line 2111: return entry.keboola_id). The annotation lies.
pull() (line 280) then calls _ensure_branch_registered(manifest, "<id-as-string>", client) and _find_branch_path(manifest, "<id-as-string>"). Both compare:
if branch.id == branch_id:
…where branch.id is int (from the Pydantic ManifestBranch.id: int at sync/manifest.py:67) and branch_id is str. Python returns False for cross-type == between int and str, so:
_ensure_branch_registered (line 2548) thinks the branch is unregistered → falls through to the registration block → path-collision logic (line 2586) sees branch-38721 is taken → suffixes to branch-38721-38721 → manifest.branches.append(...). Pydantic coerces the string id back to int on append, so the entry lands as {id: 38721, path: "branch-38721-38721"}. The same (id, path) mutation runs on every pull.
_find_branch_path (line 2593) hits the same False comparison → falls back to manifest.branches[0].path → returns "main". Files get routed to main/.
- The branch-name lookup inside
_ensure_branch_registered (line 2575: if b.get("id") == branch_id) also fails for the same reason — Storage API returns id as int, our branch_id is str — so the human-readable name from the API never gets used and we always fall through to f"branch-{branch_id}". This explains why naming.branch = "{branch_name}" produced branch-38721 instead of a slug.
So one type bug produces three visible symptoms: misrouted files, manifest growth, and missing branch slug.
Existing test that locks the bug in
tests/test_sync_service.py:1613:
assert entry.keboola_id == "99999"
This assertion encodes the wrong contract. After the fix it must change to == 99999 (int).
Bug B — sync diff / sync push --dry-run does not detect untracked local configs in non-default branch
Symptom
kbagent --json sync diff --project <alias>
{ "status": "ok",
"data": {
"changes": [], "remote_only": [],
"summary": { "added": 0, "modified": 0, "remote_modified": 0,
"conflict": 0, "deleted": 0, "unchanged": 0,
"remote_only": 0 } } }
kbagent --json sync push --project <alias> --dry-run
{ "status": "ok",
"data": { "status": "no_changes",
"created": 0, "updated": 0, "deleted": 0, "errors": [] } }
Per skills/kbagent/references/sync-workflow.md — | ADDED | New local config | Push creates it | — an untracked local config dir is supposed to be surfaced as diff state "added" and POSTed by push. The "scaffold locally then push" workflow is silently broken.
Repro
kbagent sync init --git-branching against an empty (or near-empty) project — manifest's configurations: [] is empty.
kbagent sync pull --project <alias> (no-op against an empty project, but auto-registers default branch).
- On a feature git branch,
kbagent sync branch-link --branch <DEV_BRANCH_ID>.
- Manually create a config directory matching default naming under the dev-branch path:
branch-<id>/application/<component-id>/<config-name>/
_config.yml
code.py
pyproject.toml
- Run
kbagent sync diff and kbagent sync push --dry-run → both return empty. No way to push the new config to Keboola via sync.
Root cause — walker piggybacks on manifest.configurations
services/sync_service.py:_find_untracked_configs (line 2612) builds the set of branch IDs to scan exclusively from already-tracked configurations:
for cfg in manifest.configurations:
branch_path = self._find_branch_path(manifest, cfg.branch_id)
tracked_paths.add(str(project_root / branch_path / cfg.path))
active_branch_ids.add(cfg.branch_id)
...
for branch in manifest.branches:
# Only scan branches that have tracked configs — skip inactive
# branch directories to avoid phantom "added" configs.
if branch.id not in active_branch_ids:
continue
When manifest.configurations: [], active_branch_ids is empty → the continue skips every branch → no directory is ever walked → added: [].
The guard ("skip inactive branch directories to avoid phantom adds") is legitimate for orphaned dev-branch dirs left over from old work, but its scope is too narrow: it also blocks the documented first-time scaffold flow. There is no way to bootstrap a config locally through sync push against a clean configurations: [] manifest, contradicting the sync-workflow.md docs.
Why this fix depends on Bug A
Even after _find_untracked_configs is widened, services/sync_service.py:906 (in diff()) computes the path for newly-found untracked configs as:
branch_path = self._find_branch_path(manifest, branch_id)
…where branch_id is the value returned by _resolve_branch_id, which today returns a str due to Bug A. _find_branch_path then fails the branch.id (int) == branch_id (str) comparison and falls back to "main", so even with a wider walker scope, untracked configs found in branch-38721/ would be reported with path rooted at main/. Bug A must be fixed first or simultaneously.
Proposed fix (single PR)
Bug A — make branch IDs int end-to-end
services/sync_service.py lines 1957, 1967, 1971, 1981, 1985: drop the str(...) wrapper:
kbc_branch_id = int(branch_info["id"])
sync/branch_mapping.py line 19: change annotation keboola_id: str | None → keboola_id: int | None. Update to_dict() / from_dict() accordingly. from_dict should coerce entry.get("id") to int (with None handling) so any legacy branch-mapping.json files written by older kbagent versions auto-migrate on first read — no breaking change for users who already linked branches.
tests/test_sync_service.py line 1613: update the asserted type to int (== 99999).
- (Defense in depth) Make
_ensure_branch_registered idempotent on (id, path) even after the type fix so any future regression here doesn't silently mutate the manifest.
Bug B — widen walker scope without re-opening the phantom-add hole
Replace the active_branch_ids guard in _find_untracked_configs with a wider source set:
- All branch IDs in
manifest.configurations[] (today's set — keeps protection against orphaned dirs that were once tracked then deleted).
- The branch ID currently resolved by
_resolve_branch_id() for this pull / diff / push call — the user is on a linked git branch and explicitly asked to operate against it.
- The
defaultBranch ID from manifest.git_branching (always allowed — push to main is a legitimate first-time flow too).
Pass the resolved branch_id into _find_untracked_configs(...) from diff() and push() so the walker has the explicit signal. Phantom-add protection is preserved for dirs from random other branches no one is currently working on.
Regression tests
- Bug A: pre-populated manifest with
branches = [{id: 38516, path: "main"}, {id: 38721, path: "branch-38721"}], mocked branch-mapping.json with keboola_id set, run pull twice; assert len(manifest.branches) == 2 after both pulls and branch_dir == "branch-38721" in the response.
- Bug B:
manifest.configurations: [], freshly scaffolded dir under a non-default branches[].path, expect diff to report added: 1 and push --dry-run to report created: 1.
Asks for the maintainer
- Confirm the intended contract for
BranchMappingEntry.keboola_id is int (and only the persisted JSON is string-of-int) or that it should be string-typed throughout. The fix above assumes the former.
- Decide on legacy-
branch-mapping.json migration strategy — silent coerce on load (the simplest), or explicit "manifest schema upgrade" message.
- Confirm whether
ADDED → push creates it is an intended supported flow on git-branching workspaces today. The docs (sync-workflow.md) imply yes; current behavior says no. If the answer becomes "no, must use tool call create_config first", the docs need a clear caveat and a hint on sync push --dry-run like:
{ "warning": "Found 1 local directory matching naming convention but not in manifest; create via 'kbagent tool call create_config' first." }
- The current Bug A fall-through emits a
WARNING and continues with a wrong-branch write — consider promoting to an error with non-zero exit even after the type fix, since "I don't recognize this branch" is not a recoverable state for sync pull.
Workaround (until fix)
For Bug A: after every sync pull, manually move main/<config-path>/* to branch-<id>/<config-path>/ and hand-edit .keboola/manifest.json to remove the {"id": <id>, "path": "branch-<id>-<id>..."} entries from branches[].
For Bug B: bootstrap the config via the MCP tool, then let sync track it:
kbagent --json tool call create_config --project <alias> --branch <DEV_BRANCH_ID> --input @stub.json
kbagent sync pull --project <alias> --force
# Now sync push detects MODIFIED on edits as expected.
This works but defeats the GitOps "scaffold locally, then push" flow that the docs advertise.
TL;DR
Two chained bugs in
kbagent syncmake GitOps workflows unusable ongitBranching.enabled: trueworkspaces:Bug A —
kbagent sync pullmis-routes dev-branch configs intomain/and corrupts.keboola/manifest.jsonby appending a duplicatebranches[]entry on every invocation. Root cause: Keboola branch IDs are persisted asstrinbranch-mapping.jsonbut compared asinteverywhere else, so every cross-type==lookup silently fails.Bug B —
kbagent sync diffandkbagent sync push --dry-runsilently report "no changes" when the user scaffolds a brand-new local config dir under a non-default branch path. The "scaffold locally then push" flow advertised by the docs is unreachable on git-branching workspaces.The two bugs are independent at the symptom level but Bug A blocks a complete fix for Bug B (the path-resolution helper that Bug B's walker delegates to is itself broken by Bug A). They should be fixed together in one PR.
Reported externally against v0.27.0; reproduced in code review on v0.30.3 (current
main). Both bugs are still latent.Shared environment
gitBranching.enabled: true,defaultBranch: main.kbagent sync branch-link --branch <DEV_BRANCH_ID>to a non-default Keboola dev branch.Bug A —
sync pullmis-routes tomain/and inflatesbranches[]Symptom
Single warning line on stderr:
— even though
{"id": 38721, "path": "branch-38721"}is plainly present inmanifest.branches[]. The lookup fails, the fallback writes tomain/, and a separate code path then re-registers the "missing" branch under a mangled path.Repro
branch_dir: "main":{ "status": "ok", "data": { "status": "pulled", "branch_id": "38721", "branch_dir": "main", "configs_pulled": 1, "details": [ { "action": "updated", "component_id": "<component-id>", "path": "application/<component-id>/<config-name>" } ] } }main/application/<component-id>/<config-name>/instead ofbranch-38721/....manifest.branches[]grows by one entry per pull:{ "id": 38721, "path": "branch-38721-38721", "metadata": {} }sync pullappends another duplicate (branch-38721-38721-38721, etc.).manifest.configurations[]records"branchId": 38721with the correct relative path for the dev branch — so the manifest now misdescribes where files actually live.Root cause —
branch_idisstrinbranch-mapping.json,inteverywhere elsebranch_linkconverts the Keboola branch ID to a string before persisting it.services/sync_service.pylines 1957, 1967, 1971, 1981, 1985:That string lands on
BranchMappingEntry.keboola_id, whose annotation insync/branch_mapping.py:19is alreadystr | None— the bug is locked in by the type annotation._resolve_branch_id(services/sync_service.py:2085) is annotated-> int | Nonebut actually returns the string straight from the mapping (line 2111:return entry.keboola_id). The annotation lies.pull()(line 280) then calls_ensure_branch_registered(manifest, "<id-as-string>", client)and_find_branch_path(manifest, "<id-as-string>"). Both compare:…where
branch.idisint(from the PydanticManifestBranch.id: intatsync/manifest.py:67) andbranch_idisstr. Python returnsFalsefor cross-type==betweenintandstr, so:_ensure_branch_registered(line 2548) thinks the branch is unregistered → falls through to the registration block → path-collision logic (line 2586) seesbranch-38721is taken → suffixes tobranch-38721-38721→manifest.branches.append(...). Pydantic coerces the stringidback tointon append, so the entry lands as{id: 38721, path: "branch-38721-38721"}. The same(id, path)mutation runs on every pull._find_branch_path(line 2593) hits the sameFalsecomparison → falls back tomanifest.branches[0].path→ returns"main". Files get routed tomain/._ensure_branch_registered(line 2575:if b.get("id") == branch_id) also fails for the same reason — Storage API returnsidasint, ourbranch_idisstr— so the human-readable name from the API never gets used and we always fall through tof"branch-{branch_id}". This explains whynaming.branch = "{branch_name}"producedbranch-38721instead of a slug.So one type bug produces three visible symptoms: misrouted files, manifest growth, and missing branch slug.
Existing test that locks the bug in
tests/test_sync_service.py:1613:This assertion encodes the wrong contract. After the fix it must change to
== 99999(int).Bug B —
sync diff/sync push --dry-rundoes not detect untracked local configs in non-default branchSymptom
{ "status": "ok", "data": { "changes": [], "remote_only": [], "summary": { "added": 0, "modified": 0, "remote_modified": 0, "conflict": 0, "deleted": 0, "unchanged": 0, "remote_only": 0 } } }{ "status": "ok", "data": { "status": "no_changes", "created": 0, "updated": 0, "deleted": 0, "errors": [] } }Per
skills/kbagent/references/sync-workflow.md—| ADDED | New local config | Push creates it |— an untracked local config dir is supposed to be surfaced asdiffstate"added"and POSTed by push. The "scaffold locally then push" workflow is silently broken.Repro
kbagent sync init --git-branchingagainst an empty (or near-empty) project — manifest'sconfigurations: []is empty.kbagent sync pull --project <alias>(no-op against an empty project, but auto-registers default branch).kbagent sync branch-link --branch <DEV_BRANCH_ID>.kbagent sync diffandkbagent sync push --dry-run→ both return empty. No way to push the new config to Keboola viasync.Root cause — walker piggybacks on
manifest.configurationsservices/sync_service.py:_find_untracked_configs(line 2612) builds the set of branch IDs to scan exclusively from already-tracked configurations:When
manifest.configurations: [],active_branch_idsis empty → thecontinueskips every branch → no directory is ever walked →added: [].The guard ("skip inactive branch directories to avoid phantom adds") is legitimate for orphaned dev-branch dirs left over from old work, but its scope is too narrow: it also blocks the documented first-time scaffold flow. There is no way to bootstrap a config locally through
sync pushagainst a cleanconfigurations: []manifest, contradicting thesync-workflow.mddocs.Why this fix depends on Bug A
Even after
_find_untracked_configsis widened,services/sync_service.py:906(indiff()) computes the path for newly-found untracked configs as:…where
branch_idis the value returned by_resolve_branch_id, which today returns astrdue to Bug A._find_branch_paththen fails thebranch.id (int) == branch_id (str)comparison and falls back to"main", so even with a wider walker scope, untracked configs found inbranch-38721/would be reported withpathrooted atmain/. Bug A must be fixed first or simultaneously.Proposed fix (single PR)
Bug A — make branch IDs
intend-to-endservices/sync_service.pylines 1957, 1967, 1971, 1981, 1985: drop thestr(...)wrapper:sync/branch_mapping.pyline 19: change annotationkeboola_id: str | None→keboola_id: int | None. Updateto_dict()/from_dict()accordingly.from_dictshould coerceentry.get("id")toint(withNonehandling) so any legacybranch-mapping.jsonfiles written by older kbagent versions auto-migrate on first read — no breaking change for users who already linked branches.tests/test_sync_service.pyline 1613: update the asserted type to int (== 99999)._ensure_branch_registeredidempotent on(id, path)even after the type fix so any future regression here doesn't silently mutate the manifest.Bug B — widen walker scope without re-opening the phantom-add hole
Replace the
active_branch_idsguard in_find_untracked_configswith a wider source set:manifest.configurations[](today's set — keeps protection against orphaned dirs that were once tracked then deleted)._resolve_branch_id()for thispull/diff/pushcall — the user is on a linked git branch and explicitly asked to operate against it.defaultBranchID frommanifest.git_branching(always allowed — push to main is a legitimate first-time flow too).Pass the resolved
branch_idinto_find_untracked_configs(...)fromdiff()andpush()so the walker has the explicit signal. Phantom-add protection is preserved for dirs from random other branches no one is currently working on.Regression tests
branches = [{id: 38516, path: "main"}, {id: 38721, path: "branch-38721"}], mockedbranch-mapping.jsonwithkeboola_idset, runpulltwice; assertlen(manifest.branches) == 2after both pulls andbranch_dir == "branch-38721"in the response.manifest.configurations: [], freshly scaffolded dir under a non-defaultbranches[].path, expectdiffto reportadded: 1andpush --dry-runto reportcreated: 1.Asks for the maintainer
BranchMappingEntry.keboola_idisint(and only the persisted JSON is string-of-int) or that it should be string-typed throughout. The fix above assumes the former.branch-mapping.jsonmigration strategy — silent coerce on load (the simplest), or explicit "manifest schema upgrade" message.ADDED → push creates itis an intended supported flow on git-branching workspaces today. The docs (sync-workflow.md) imply yes; current behavior says no. If the answer becomes "no, must usetool call create_configfirst", the docs need a clear caveat and a hint onsync push --dry-runlike:{ "warning": "Found 1 local directory matching naming convention but not in manifest; create via 'kbagent tool call create_config' first." }WARNINGand continues with a wrong-branch write — consider promoting to an error with non-zero exit even after the type fix, since "I don't recognize this branch" is not a recoverable state forsync pull.Workaround (until fix)
For Bug A: after every
sync pull, manually movemain/<config-path>/*tobranch-<id>/<config-path>/and hand-edit.keboola/manifest.jsonto remove the{"id": <id>, "path": "branch-<id>-<id>..."}entries frombranches[].For Bug B: bootstrap the config via the MCP tool, then let
synctrack it:This works but defeats the GitOps "scaffold locally, then push" flow that the docs advertise.