Skip to content

fix: dedup dependency auto-apply to prevent duplicate tool mints (Gap #10)#23

Merged
dhruva-reddy merged 6 commits intomainfrom
chore/gap-10-fix
May 10, 2026
Merged

fix: dedup dependency auto-apply to prevent duplicate tool mints (Gap #10)#23
dhruva-reddy merged 6 commits intomainfrom
chore/gap-10-fix

Conversation

@dhruva-reddy
Copy link
Copy Markdown
Contributor

Summary

Fixes Gap #10 — targeted assistant pushes were minting duplicate dashboard tools when bootstrap pull stored an existing dashboard tool under a name-slugged state key (e.g. end-call-67aea057) instead of the user's original local key (b2b-invoice-end-call). The exact-key lookup in ensureToolExists / ensureStructuredOutputExists missed and POSTed a fresh duplicate; each subsequent targeted push repeated the cycle, accumulating dashboard orphans.

What changed

src/dep-dedup.ts (new) — pure-logic helpers (slugify, extractBaseSlug, extractResourceName, findExistingResourceByName). Imports only ./types.ts so it's testable without booting config.ts's CLI parse.

src/push.tsensureToolExists and ensureStructuredOutputExists now run a name-based dedup check between the exact-key short-circuit and the create path:

  1. State-side: scan state for any key whose extractBaseSlug matches the local payload's slugified name (catches bootstrap-renamed keys).
  2. Dashboard-side: lazy-fetch the live /tool (or /structured-output) list once per push, cached on DependencyContext, and check for a remote resource with the same canonical name.

When >1 distinct UUID matches the same name (real on-dashboard duplicates from prior bug runs), pick the lex-smallest UUID for stable, deterministic adoption; warn naming the loser UUIDs; point at npm run cleanup. Never mint another duplicate.

Adoption flow handles two lifecycle traps surfaced in code review:

  • Orphan-deletion guard: after re-keying state to the adopted UUID, drop other state keys pointing at the same UUID and mark them touched, so a subsequent full push (--force) doesn't see them as "tracked but no local file" and DELETE the dashboard resource we just adopted. (Stack J / mergeScoped flushes the deletion.)
  • Drift-check + local-edit safety: adoption routes through applyTool / applyStructuredOutput (which take the PATCH path because existingUuid is now set), running the standard checkDriftForUpdate flow and pushing the local payload. Avoids recording a fake lastPushedHash that would silently drop locally-edited dependencies on the next push.

tests/dep-dedup.test.ts (new, 15 tests) — covers findExistingResourceByName for: state-only match, dashboard-only match, both-agree, ambiguous (state-vs-state and state-vs-dashboard), no-name payload, exact-key-excluded (caller's job), no-match, empty inputs, top-level-name-wins, remote payload using top-level name.

Test plan

  • npm run build clean (tsc --noEmit)
  • npm test 114/114 pass (was 99 before; +15 new dep-dedup tests + 0 regressions)
  • Pipeline followed: planner (inline) → implementer → test-writer → code-reviewer (2 blocking findings → fixed → re-implemented)
  • Smoke against a sandbox env with a state file containing a bootstrap-renamed tool entry — relies on unit coverage; integration harness would require a stub API server (documented in test-writer report as deliberately skipped)

Skipped / non-blocking notes from review

  • Dry-run dashboard-side dedup: getExistingRemoteTools returns [] in dry-run (no API call), so dry-run only exercises state-side dedup. Acceptable: dry-run is meant to be cheap; the state-side check covers the common case.
  • Lex-smallest UUID tiebreaker: deterministic but arbitrary. A future "UUID most-referenced by current assistants" rule would be more correct but requires a graph walk; deferred until ambiguity proves a real problem in practice.
  • slugify / extractBaseSlug deliberately duplicated between pull.ts and dep-dedup.ts so the new module stays config-free for testability — file header documents the rationale.

Improvements log

Will mark improvements.md §10 as [RESOLVED YYYY-MM-DD] (#<this-PR>) in a follow-up commit on this branch once the PR # is known.

…10)

Targeted assistant pushes minted duplicate dashboard tools when bootstrap
pull stored an existing dashboard tool under a name-slugged state key
(e.g. `end-call-67aea057`) instead of the user's original local key. The
exact-key lookup in `ensureToolExists` / `ensureStructuredOutputExists`
missed and POSTed a fresh duplicate. Each subsequent targeted push
repeated the cycle, accumulating dashboard orphans.

This adds a name-based dedup check between the exact-key short-circuit
and the create path, in two layers:

  1. State-side: scan state for any key whose `extractBaseSlug` matches
     the local payload's slugified name (handles bootstrap-renamed keys).
  2. Dashboard-side: lazy-fetch the live `/tool` (and `/structured-output`)
     list once per push and check for a remote resource with the same
     canonical name.

When >1 distinct UUID matches the same name (real on-dashboard duplicates
from prior bug runs), pick the lex-smallest UUID for stable adoption,
warn naming the loser UUIDs, and point at `npm run cleanup`. Never mint
another duplicate.

Adoption flow:
  - Re-key state to the adopted UUID under the local resourceId.
  - Drop other state keys pointing at the same UUID and mark them
    touched, so a subsequent full push doesn't orphan-delete the
    adopted dashboard resource (Stack J / mergeScoped flushes the
    deletion).
  - Route through `applyTool`/`applyStructuredOutput` so the local
    payload PATCHes the dashboard with the standard drift-check flow,
    instead of recording a fake `lastPushedHash` that would silently
    drop a locally-edited dependency.

Tests: 12 unit tests for `findExistingResourceByName` covering state-only,
dashboard-only, both-agree, ambiguous (state-vs-state, state-vs-dashboard),
no-name, exact-key-excluded, no-match. All 114 suites pass.

Refs: improvements.md §10
…CLAUDE.md

- src/dep-dedup.ts: drop "Gap #10" issue marker from the file header (it
  rots; the rationale is what matters, not the tracker reference).
- docs/learnings/tools.md: new section "Renaming a tool file is safe — the
  engine dedups by `function.name`" — explains the auto-apply dedup safety
  net, the 🔁 / ⚠️ log line semantics, and the cleanup path. Counterpart in
  docs/learnings/structured-outputs.md cross-references it.
- AGENTS.md: add `outbound-campaigns.md` to the Learnings & recipes table
  (was missing); refresh the docs/learnings/ tree in the Project Structure
  section to be complete; add an explicit "Where new knowledge goes" table
  pinning the convention (per-resource tips → docs/learnings/<topic>.md;
  engine-friction log → improvements.md; rationale → code comments;
  onboarding → README.md).
- CLAUDE.md: sync the Required Reading Order list with AGENTS.md's table
  (was missing voice-providers, outbound-agents, outbound-campaigns,
  voicemail-detection); add a brief "Where new knowledge goes" reminder
  pointing back at AGENTS.md as the canonical convention table.

No source behavior changes. Build clean, 114/114 tests pass.
…identifier refs from comments

Code-review follow-ups on PR #23:

- src/dep-dedup.ts: replace `Record<string, unknown>` + `as`-cast with a
  named `NameablePayload = { name?: unknown; function?: unknown }` shape
  and `in`-operator narrowing. No casts, no laundered types — the function
  reads two known paths and narrows them at use.

- src/push.ts: scrub "Gap #10" / "Stack J" / "improvements.md #15"
  identifiers from comments. These were internal stack/log markers that
  don't help anyone reading the code; rephrased in domain language while
  keeping the rationale. Also drops redundant `as Record<string, unknown>`
  casts at call sites and reuses `extractResourceName` for the display-name
  fallback in dedup warnings.

- src/push.ts: extend dedup to assistants. The squad → assistant
  auto-apply path (`ensureAssistantExists`) had the same bug class as
  tools / SOs — bootstrap pull stores assistants under `<slug>-<uuid8>`
  keys, and a squad referencing the original local key would mint a
  duplicate assistant on every push. Adds `getExistingRemoteAssistants`
  lazy-fetch + dedup branch with the same orphan-deletion guard and
  apply-via-PATCH flow already in place for tools / SOs. Documents in
  the DependencyContext comment why simulations / personalities /
  scenarios / sim-suites are NOT covered: they're not auto-applied as
  dependencies anywhere in the engine, so the bug class doesn't fire.

- tests/dep-dedup.test.ts: add explicit assistant-payload test
  (top-level `name`, no nested `function`).

Build clean, 115/115 tests pass (was 114, +1 new assistant test).
Two cleanup sweeps prompted by review feedback on PR #23:

Sweep 1 — internal stack/log identifiers in code comments. References
to "Stack F/G/H/I/J" and "improvements.md #N" are internal-only and
mean nothing to a customer reading the code. Each comment is rephrased
in domain language while preserving the WHY:

  - Stack F → "per-resource content-hash state schema" / "schema migration"
  - Stack G → "drift detection layer"
  - Stack H → "snapshot-on-push for rollback"
  - Stack I → "ETag-based optimistic concurrency"
  - Stack J → "scoped state writes"
  - improvements.md #N → dropped entirely (the rationale stands on its own)

Touched: src/api.ts, cleanup.ts, dep-dedup.ts, drift.ts, pull.ts, push.ts,
resolver.ts, sim-cmd.ts, sim.ts, snapshot.ts, state-merge.ts,
state-serialize.ts, types.ts.

Sweep 2 — customer-specific identifiers in docs/learnings. Customer
brand names (`iForm`, `Mudflap`) and internal ticket IDs
(`PRISM-481`, `PRISM-528`, `PRISM-474`) replaced with generic
placeholders so the public template doesn't carry customer artifacts:

  - iForm → Acme Logistics (in scenario examples)
  - Mudflap → "a customer rollout" (in cross-references)
  - PRISM-* tickets → dropped entirely
  - handoffToiFormSales → handoffToAcmeSales
  - b2b-invoice-end-call.yml → intake-end-call.yml (in renaming example)

Touched: docs/learnings/assistants.md, simulations.md, squads.md, tools.md,
voice-providers.md.

No source-behavior changes. Build clean, 115/115 tests pass.
@dhruva-reddy dhruva-reddy merged commit 170e331 into main May 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant